2. Create the FHIR Service
Generate the Service
For this tutorial, you will create a new FHIR service with the artifactId fhir-starter-service. The mvn archetype:generate command below generates the service from our FHIR archetype. Run it from the terminal in the root of your starter-project directory, on the branch you created:
mvn org.apache.maven.plugins:maven-archetype-plugin:3.4.1:generate \
-DarchetypeGroupId=gov.va.mobile.archetypes \
-DarchetypeArtifactId=fhir-module-archetype \
-DarchetypeVersion=3.0.4 \
-DgroupId=gov.va.mobile.service \
-DartifactId=fhir-starter-service \
-Dversion=1.0.0 \
-Dshort-name=fhirstarter
The short-name property is required. It must start with a lowercase letter and contain only lowercase letters and digits, because the archetype uses it to build:
-
the Java package,
gov.va.mobile.fhirstarter.v1 -
the service base path (context path),
/fhirstarter/v1 -
the prefix of the service’s own properties,
va.mobile.fhirstarter
The archetype also puts the FHIR server under /fhir (the fhir-server-path property), so the FHIR base URL of your service is /fhirstarter/v1/fhir. The dtr-org and jira-project properties default to ckm and CKM, which are right for the starter project.
Maven then asks for the remaining properties. Press Enter to accept each default, and enter Y at the end to confirm. Below is an example of the output:
[INFO] Using property: short-name = fhirstarter [INFO] Using property: dtr-org = ckm [INFO] Using property: jira-project = CKM ... [INFO] Using property: fhir-server-path = /fhir/* [INFO] Using property: groupId = gov.va.mobile.service [INFO] Using property: artifactId = fhir-starter-service [INFO] Using property: version = 1.0.0 Define value for property 'base-package' gov.va.mobile.fhirstarter.v1: Define value for property 'package' gov.va.mobile.fhirstarter.v1: Define value for property 'service-base-path' /fhirstarter/v1: Define value for property 'formatted-short-name' Fhirstarter: Confirm properties configuration: short-name: fhirstarter ... package: gov.va.mobile.fhirstarter.v1 service-base-path: /fhirstarter/v1 formatted-short-name: Fhirstarter Y: : y
Near the end of the output, Maven prints a few warnings such as [WARNING] CP Don’t override file …/fhir-starter-service/.mvn. They are expected, and you can ignore them.
If everything goes well, you have a new folder named fhir-starter-service, and the archetype prints the next steps:
Generated fhir-starter-service in .../starter-project/fhir-starter-service
Next steps:
1. Run the setup script. It initializes the git repository, generates the Kubernetes, Skaffold, and pipeline files
with filegen, applies the single-module fixes to skaffold.yaml, and sets the Dynatrace labels:
cd fhir-starter-service && ./setup.sh
2. Build, deploy, and test the service locally:
./mvnw clean install -Pwith-skaffold
Java 21 is required to build this service. If you use SDKMAN, run sdk env install in the fhir-starter-service directory once; it installs the Java and Maven versions from the .sdkmanrc file. If sdkman_auto_env=true is set in ~/.sdkman/etc/config (the setup script sets it), SDKMAN switches to those versions whenever you cd into the directory. Otherwise, run sdk env in each new terminal.
|
At this point, open fhir-starter-service as a new project in your IDE. This will help with navigation and code completion as you work on the service.
|
Set Up the Service
Before you can build the service, a few more files need to be generated and adjusted: the Kubernetes, Skaffold, and Jenkins files, the deployment documentation, and the Backstage catalog files. The archetype includes a script, setup.sh, that does all of this for you.
Before you run it, make sure that:
-
You are connected to the VPN, and your
~/.m2/settings.xmlhas your coderepo credentials in thebitbucketserver. The file generator (filegen) downloads its templates from coderepo. -
Your git name and email are set (
git config --global user.nameandgit config --global user.email).
Then run the script from the service directory:
cd fhir-starter-service
./setup.sh
Press Enter at every question to accept the default, or run ./setup.sh -y, which accepts every default without asking. The script shows each command before it runs it. It takes a few minutes, mostly while filegen downloads its templates from coderepo.
When the script finishes, your branch has two new commits, WIP: initial commit and Generated files added. Finally, the script offers to delete itself; press Enter (y).
The script lists SET_ME placeholders in catalog-info.yaml, mkdocs.yaml, and vv.yaml. filegen fills them in from the git remote of the service’s own repository, and your service lives inside the starter-project repository instead. This is expected for the starter project, and you can ignore them.
|
For what each step of setup.sh does, and the equivalent manual steps, see What setup.sh Does in the appendix. You don’t need them for this guide.
Tour the Generated Project
The archetype generated more than an empty service. It includes sample code for the patterns our FHIR services use, and you will build on that code throughout this guide. The Java code is in src/main/java/gov/va/mobile/fhirstarter/v1:
| File | What it shows | Extended on page |
|---|---|---|
|
A resource provider for |
|
|
The ICN validation and the veteran access check, shared by every provider that returns patient data. The provider calls it in each operation |
|
|
A system-level operation, |
|
|
Customizes the FHIR server: it registers |
|
|
A catalog of the error messages returned to clients, and a HAPI exception that results in HTTP 400 with an |
|
|
The service’s own settings, bound from the |
|
|
The base configuration, and one file per Spring profile ( |
|
|
Unit tests for the samples; |
|
|
Service metadata and documentation, with |
Three ideas from the generated code come up on every page:
-
Registration. The Mobile Framework registers every Spring bean that implements
IResourceProvider(such asPatientProvider) with the FHIR server automatically. Anything else, such asSystemOperationsProvider, which only has operations, is registered inFhirConfig. -
Who may call, and which patient. The
@JwtRestrictedand@JwtRestrictedGroupannotations decide which roles may call a provider. They can’t know which patient a request is for, so each operation checks that withPatientAccess: a veteran may only request their own records, identified by the ICN in their JWT. -
Errors. A resource provider reports an error by throwing a HAPI exception, such as
InvalidRequestException(HTTP 400) orForbiddenOperationException(HTTP 403). HAPI turns it into the HTTP status and anOperationOutcomeresponse.
Build and Run the Service
Build and Test Everything
First, make sure the generated service builds. Run a Maven install with the with-skaffold profile from the fhir-starter-service directory. It builds the service, deploys it and its dependencies to your local cluster, runs the unit tests (29 of them) and the integration tests (14), checks code coverage and code quality, and then removes the deployment:
./mvnw clean install -Pwith-skaffold
| You will see a warning at the beginning of the build that says 'The requested profile "with-skaffold" could not be activated because it does not exist.' This is a side effect of the way the service pom file is constructed from tiles during the build process and is expected behavior. You can safely ignore it as long as the build completes successfully. |
The service log in the build output contains ERROR lines for AuthenticationException and ForbiddenOperationException. They come from the integration tests that check that requests without permission are rejected.
|
|
If the build seems to stop after |
| Make sure this build passes before you change anything. If it fails, work with the Onboarding team to resolve the problem. |
Run the Service for Testing
To call the service yourself, keep it running with skaffold dev. This builds the service image, deploys it to your local cluster, forwards its ports (8080 for the service, 8081 for the actuator endpoints), and keeps showing the service log. The first start takes a minute or two, because Skaffold also runs the unit tests; wait until you see Press Ctrl+C to exit.
skaffold dev --rpc-port 50051 --port-forward=true
To stop the service, press Ctrl-C. To make sure that everything Skaffold deployed is cleaned up, run:
skaffold delete
|
If you would like to start the service with the remote debugging port (5005) exposed, run:
See Skaffold Debugging with IntelliJ for instructions on configuring the remote debugger. |
Your Development Loop
On the following pages, you change the service again and again. This is how to get each kind of change into your running service, and how to test it. Come back to this table whenever a page asks you to restart skaffold dev or run the build.
| When you… | Do this |
|---|---|
Change Java code |
Nothing. |
Change files in the |
Stop |
Change |
Stop |
Want a quick check of the unit tests and code quality |
|
Want to run the integration tests against your running service |
Start the service with |
Are about to commit |
Stop |
The Kubernetes Files Skaffold Deploys
Skaffold doesn’t deploy the files in kubernetes/base and kubernetes/components directly. Before each deployment, scripts/localize.sh copies them, together with the shared files they reference on coderepo, into kubernetes/localized, and Skaffold deploys those copies. Never edit the files in kubernetes/localized; your changes would be overwritten.
To save time, localize.sh only makes new copies when the files git tracks in kubernetes change. A file you added but haven’t committed yet isn’t tracked, so a fix to it isn’t copied, and Skaffold deploys the old version again. ./scripts/localize.sh force always makes new copies; it takes about 10 seconds. (Running git add kubernetes before you restart works too.)
When Something Goes Wrong
skaffold dev doesn’t reach Press Ctrl+C to exit
Your service only becomes ready when the services it depends on are healthy: WireMock (mvi), and from page 7 on, cerner-mock (cerner). If one of them can’t start, for example because a stub file isn’t valid JSON, skaffold dev waits about 5 minutes, prints 1/2 deployment(s) failed or similar, and removes the deployment. Don’t wait that long. In another terminal, look at the pods:
kubectl get pods -n fhir-starter-service-test
A pod whose STATUS is CrashLoopBackOff or Error, or whose READY column stays at 0/1, is the problem. Read its log, using the start of the pod’s name:
kubectl logs -n fhir-starter-service-test deploy/wiremock
kubectl logs -n fhir-starter-service-test deploy/fhir-starter-service-v1
For example, a broken stub makes WireMock log MappingFileException: Error loading file … with the file name. Fix the file, stop skaffold dev, run ./scripts/localize.sh force, and start it again.
To check your stub and cerner-mock files before you start, run this from the service directory. It prints each file that isn’t valid JSON, and each WireMock stub that isn’t a single object with request and response:
for f in $(find kubernetes/components -name '*.json'); do
case $f in
*/wiremock-data/*) check='has("request") and has("response")' ;;
*) check='type == "object"' ;;
esac
jq -e "$check" "$f" > /dev/null 2>&1 || echo "Check $f"
done
The build fails with SkaffoldRenderCheckRule
The build’s service-lint check runs skaffold render for the staging and production profiles, and reports any failure as:
[ERROR] [SkaffoldRenderCheckRule] Failed to execute skaffold render for profile 'staging-build-test': skaffold render failed with exit code 1
The most common cause is a timeout while localize.sh downloads the shared Kubernetes files from coderepo. Check your VPN connection, and run the build again. If it fails again, run the same command that the check runs, which shows the real error:
SKAFFOLD_RPC_PORT=50052 skaffold render --digest-source=tag -p staging-build-test > /dev/null
Leave out --digest-source=tag, and skaffold render fails with an unrelated error, failed to resolve the digest of …: does the image exist remotely?.
A curl request returns 401 No JWT included in request
Your JWT variables only exist in the terminal where you set them. Run source ~/fhir-starter-jwts.sh (see Get JWTs with jwtgen) in the terminal where you run curl. If the JWTs have expired, generate new ones.
Add the Generated Files to Git
The first build generates a few more files: the CapabilityStatement and OpenAPI documents in docs/, a local copy of the Kubernetes files in kubernetes/localized, and .kubernetes-hash.sha256. It also fills in the ${basePath} placeholders in docs/dibr.md. Commit them, then push your branch:
git add .
git commit -m "Add files generated by the first build"
git push -u origin fhir_starter_<firstname>_<lastname>
From now on, most of your commits include changes in kubernetes/localized, often 20 files or more, because localize.sh makes new copies whenever the Kubernetes files change. Commit them with your changes, but never edit them. To keep IntelliJ’s search from finding them, right-click kubernetes/localized and choose Mark Directory as > Excluded.
|
Every integration test run writes docs/capability-statement.json again, with a new id and date. Commit the file whenever the service interface changes.
|
Testing the Service
Start the service with skaffold dev --rpc-port 50051 --port-forward=true and wait until it is running.
Explore the Service
A few endpoints don’t need a JWT. Open them in your browser:
-
http://localhost:8080/fhirstarter/v1 - The service base path redirects to the CapabilityStatement.
-
http://localhost:8080/fhirstarter/v1/fhir/metadata - The CapabilityStatement. Find the
Patientresource, with thereadandsearch-typeinteractions and theidentifiersearch parameter, and thevalidate-icnoperation. HAPI generated all of this from the annotations inPatientProviderandSystemOperationsProvider. -
http://localhost:8080/fhirstarter/v1/fhir/swagger-ui/ - Swagger UI for the FHIR API.
-
http://localhost:8080/fhirstarter/v1/fhir/api-docs - The OpenAPI document that Swagger UI displays.
Get JWTs with jwtgen
The other endpoints are JWT protected. To generate JWTs for testing, you need a tool we have called jwtgen. Clone the jwtgen repository to a directory outside of your starter-project, and build it:
git clone https://coderepo.mobilehealth.va.gov/scm/iums/jwtgen.git
cd jwtgen
./mvnw clean package
If you already have a jwtgen clone, for example in ~/Projects, where the setup script puts one, run git pull in it before you build, instead of cloning it again.
You need jwtgen 3.1.0 or later, from its main branch. Earlier versions don’t put the veteran’s ICN in the JWT’s subject, so your service refuses the veteran JWT even for the veteran’s own record, and they can’t run the commands below. The jar name contains the version; if target has a newer jwtgen-<version>.jar, use that one.
|
You need two JWTs: one for a staff user, and one for a veteran, PATIENT FIFTEEN (10115V737384). You’ll use them for the rest of the guide, in many terminals and over several days, so save them in a file in your home directory, outside the repository. In the jwtgen directory, run:
cat > ~/fhir-starter-jwts.sh <<EOF
export STAFF_JWT=$(java -jar target/jwtgen-3.1.0.jar jwtgen-staff --long-expiration --token-only)
export VETERAN_JWT=$(java -jar target/jwtgen-3.1.0.jar jwtgen-veteran --icn 10115V737384 --long-expiration --token-only)
EOF
Each java -jar command runs one jwtgen command and prints the JWT: --long-expiration makes it valid for 10 years instead of 15 minutes, and --token-only prints only the JWT. Check the file: each line should end with a long JWT that starts with eyJ.
cat ~/fhir-starter-jwts.sh
Then load the JWTs in each terminal where you run curl:
source ~/fhir-starter-jwts.sh
Run java -jar target/jwtgen-3.1.0.jar without a command to use jwtgen’s interactive shell, which prompts for the values; type help there to see every command and option.
|
You’ll have several terminals open while you work: one running skaffold dev, which keeps showing the service log, and another for curl. Page 7 adds a third.
|
Your service identifies a veteran by the ICN in the subject ( Don’t use |
Call the Service with curl
The generated $validate-icn operation checks whether a patient identifier is a valid ICN. It’s restricted to staff users.
Request:
curl -s -H "x-vamf-jwt: $STAFF_JWT" 'http://localhost:8080/fhirstarter/v1/fhir/$validate-icn?icn=10115V737384' | jq
Response:
{
"resourceType": "Parameters",
"parameter": [
{
"name": "valid",
"valueBoolean": true
}
]
}
Put the URL in single quotes. In double quotes, your shell would replace $validate with the value of an environment variable.
|
Now try the requests below. To see the HTTP status, add -i to the curl command and leave out | jq, which can’t read the response headers:
-
$validate-icn?icn=10115V737385with$STAFF_JWT: HTTP 200 with"valueBoolean":false, because the checksum of the ICN is wrong. -
$validate-icn?icn=10115V737384with$VETERAN_JWT: HTTP 403, because the operation is restricted to staff users:{ "resourceType": "OperationOutcome", "issue": [ { "severity": "error", "code": "processing", "diagnostics": "403 FORBIDDEN \"Access denied due to security policy for invalid: [role(s)]\"" } ] } -
$validate-icn?icn=10115V737384without thex-vamf-jwtheader: HTTP 401,"diagnostics":"401 UNAUTHORIZED \"No JWT included in request\"".
Next, search for a patient by ICN. A FHIR token search parameter takes a system and a value, separated by |:
curl -s -H "x-vamf-jwt: $STAFF_JWT" 'http://localhost:8080/fhirstarter/v1/fhir/Patient?identifier=urn:oid:2.16.840.1.113883.4.349%7C10115V737384' | jq
Response (HTTP 501):
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "processing",
"diagnostics": "The Patient search operation is not yet implemented"
}
]
}
Encode the | as %7C in URLs. The web server rejects a raw | with an HTML error page (HTTP 400). Alternatively, let curl encode it: curl -G --data-urlencode 'identifier=urn:oid:2.16.840.1.113883.4.349|10115V737384' …
|
The search isn’t implemented yet; that’s the next page. But it already validates the request and checks access:
-
Patient?identifier=not-an-icnwith$STAFF_JWT: HTTP 400,"diagnostics":"Invalid patient identifier, expected an ICN". The message doesn’t repeat the identifier, which could be another kind of patient identifier, such as an SSN; page 5 explains why. -
Patient?identifier=http://hl7.org/fhir/sid/us-ssn%7C10115V737384with$STAFF_JWT: HTTP 400,"diagnostics":"Invalid patient identifier system, expected urn:oid:2.16.840.1.113883.4.349". -
Patient?identifier=10132V467385with$VETERAN_JWT: HTTP 403,"diagnostics":"Access to the requested patient is not permitted", because a veteran may only request their own records. -
Patient?identifier=10115V737384with$VETERAN_JWT: HTTP 501, like the staff search. The veteran passes the access check for their own ICN, and gets as far as the search that isn’t implemented yet. -
Encounter?patient=10115V737384with$STAFF_JWT: HTTP 404,"diagnostics":"HAPI-0302: Unknown resource type 'Encounter' - Server knows how to handle: [Patient]". You will add Encounter on page 8.
Every error is an OperationOutcome, even the ones that the framework or HAPI return for you. The severity, code, and diagnostics of each issue tell the client what went wrong.
Other Clients
If you prefer Postman or your IDE’s HTTP client to curl, send the same requests there: add the x-vamf-jwt header with your JWT, and an Accept: application/fhir+json header, and write the | in a URL as %7C.
When you’re done testing, press Ctrl-C to stop skaffold dev, and run skaffold delete to clean up the deployment.
You’re Done When
-
Your service builds and its tests pass with
./mvnw clean install -Pwith-skaffold, and runs withskaffold dev -
You have looked at the CapabilityStatement and Swagger UI of your running service
-
You have called
$validate-icnand the Patient search with a staff JWT, and seen the 400, 401, 403, and 501 responses -
Your branch is pushed to the starter-project repository
Continue to 3. Implement the Patient Resource Provider to implement the Patient read and search.