Modify The Service

Update the Swagger Contract with New Endpoints

Now that you have a successful build, let’s next look at how an update to the service’s swagger contract would be performed.

We design our services by creating the contract for our public endpoints first. This enables conversations with those who depend on our services earlier in the development process, as well as allowing a natural moment to consider the design warranted for each service.

The swagger contract in the starter-service (src/main/resources/swagger.json) needs to be updated to support two new endpoints:

  • PUT /session/values/{key}

  • GET /session/values/{key}

There are many ways to modify the swagger contract, including using any text editor, or Visual Studio Code/intelliJ with helpful extensions/plugins. If you have never worked with swagger documents before, the quickest way to gain an understanding may be to paste the existing contract into the swagger editor at https://swagger.io/tools/swagger-editor/. At https://swagger.io, you can learn more about the Swagger project and the Swagger/OpenAPI spec.

When you first built your service, you may have noticed that an openapi.json file was generated in the resources directory alongside the swagger.json file. This file is generated from the swagger.json service contract to be used for endpoint cataloging only, and therefore should not be edited. The swagger contract defined in swagger.json is the source of truth for the service API definition and should be the only file edited when making changes to the service API.

Here is what the service contract should look like after the two new endpoints are added to starter-service/src/main/resources/swagger.json:

{
  "openapi": "3.0.3",
  "info": {
    "version": "@starter-service-client.version@",
    "title": "starter-service"
  },
  "tags": [
    {
      "name": "Starter"
    }
  ],
  "security": [
    {
      "requireVamfJwtKey": []
    }
  ],
  "servers": [
    {
      "description": "SQA",
      "url": "https://staff.apps-staging.va.gov/starter/v1"
    },
    {
      "description": "PROD",
      "url": "https://staff.apps.va.gov/starter/v1"
    }
  ],
  "paths": {
    "/patients/{icn}/info": {
      "get": {
        "tags": [
          "Starter"
        ],
        "description": "Retrieves the patient information.",
        "operationId": "getPatientInfo",
        "parameters": [
          {
            "name": "icn",
            "in": "path",
            "description": "The identifier of the patient to retrieve info for.",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PatientInfoResponse"
                }
              }
            }
          },
          "default": {
            "description": "Error payload"
          }
        }
      }
    },
    "/session/values/{key}": {
      "summary": "Put and Get Session values.",
      "description": "These methods allows apps and services to PUT and GET session values for this user. It will *not* accept requests from browsers (i.e. User-Agent       contains 'Mozilla') in order to prevent potential XSS threats.",
      "parameters": [
        {
          "name": "key",
          "in": "path",
          "description": "The identifier for the session.",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "summary": "Get a session value for this user/session.",
        "description": "This method allows apps and services to retrieve session values for this user. It will *not* accept requests from browsers (i.e. User-Agent       contains 'Mozilla') in order to prevent potential XSS threats.",
        "operationId": "getSessionValue",
        "responses": {
          "200": {
            "description": "Key value pair was successfully set.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "description": "Token Required"
          },
          "403": {
            "description": "Not Allowed"
          },
          "404": {
            "description": "Not Found"
          }
        },
        "tags": [
          "Session"
        ]
      },
      "put": {
        "summary": "Set a key/value pair for this user/session.",
        "description": "This method allows apps and services set session values for this user. It will *not* accept requests from browsers (i.e. User-Agent contains 'Mozilla') in order to prevent potential XSS threats.",
        "operationId": "setSessionValue",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "value": {
                    "description": "Value for the key",
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Key value pair was successfully set."
          },
          "401": {
            "description": "Token Required"
          },
          "403": {
            "description": "Not Allowed"
          },
          "404": {
            "description": "Not Found"
          }
        },
        "tags": [
          "Session"
        ]
      }
    }
  },
  "components": {
    "securitySchemes": {
      "requireVamfJwtKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-VAMF-JWT"
      }
    },
    "schemas": {
      "PatientInfo": {
        "type": "object",
        "required": [
          "icn"
        ],
        "properties": {
          "icn": {
            "type": "string",
            "description": "Patient ICN identifier"
          },
          "firstName": {
            "type": "string",
            "description": "Patient first name"
          },
          "lastName": {
            "type": "string",
            "description": "Patient last name"
          },
          "dateOfBirth": {
            "type": "string",
            "description": "Patient date of birth"
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "required": [
          "errors"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "description": "Overall Error status"
          },
          "errors": {
            "$ref": "#/components/schemas/Error"
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "status",
          "code"
        ],
        "properties": {
          "status": {
            "type": "integer",
            "description": "Error status"
          },
          "code": {
            "type": "string",
            "description": "Error code"
          },
          "detail": {
            "type": "string",
            "description": "Error detail"
          }
        }
      },
      "PatientInfoResponse": {
        "type": "object",
        "required": [
          "data"
        ],
        "properties": {
          "data": {
            "$ref": "#/components/schemas/PatientInfo"
          }
        }
      }
    }
  }
}

Take a moment to observe the following about the swagger contract modifications made above:

  • Make note of the values used for the operationId fields in the new endpoints, as these will be used when generating the request model objects from the contract. The operationId values should be unique across the entire swagger contract, and should be descriptive of the endpoint’s purpose.

  • Also note the added tags field for the new endpoints, which is used to group endpoints together in the generated client code. In this case, we used "Session" as the tag for the new endpoints, which will cause them to be grouped together in a SessionApi class in the generated client code.

At this point the project could be rebuilt, but we haven’t yet placed an actual controller behind our newly created endpoints. When you rebuild the service, the client package will parse the swagger contract and generate the API client and domain model objects. However, if the new endpoints were accessed, the result at this time would just be a 404 error.

Create a Controller for the New Endpoints

Under package gov.va.mobile.starter.v1.service.controller, create a new class named UserSessionController. Implement mappings for both the GET and the PUT endpoints we just added to the swagger contract. For now, it can just return an empty response, and we will return to an implementation later on in the project tutorial.

The implementation class should look roughly like this:

package gov.va.mobile.starter.v1.service.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * REST endpoint that allows clients to retrieve session data.
 *
 * @since 1.0
 */
@RestController
@RequestMapping(path = "/session/values/{key}")
public class UserSessionController {


    /**
     * Retrieves a session value based on the provided key.
     */
    @GetMapping
    public ResponseEntity<String> getSessionValue(@PathVariable final String key) {

        //stubbed out for now
        return new ResponseEntity<>(HttpStatus.OK);
    }

    /**
     * Updates or creates a session value based on the provided key.
     */
    @PutMapping
    public ResponseEntity<String> putSessionValue(@PathVariable final String key) {

        //stubbed out for now
        return new ResponseEntity<>(HttpStatus.OK);
    }
}

Now you can run the service with the skaffold dev command and hit the new endpoints. The response will be empty with a 200 OK status.

You should take this opportunity to inspect the generated client code for these endpoints in the starter-service-client package (starter-service-client/target/generated-sources/src/main/java/gov/va/mobile/starter/v1) to see how the swagger contract translates into client code.

Integrating With Another Service

The starter-service’s example route of patients/{icn}/info is documented to return information about a user for the provided ICN (a type of identifier). However, it is not returning the correct information; it is instead returning the name information from the JWT and the ICN value passed in from the request. In order to retrieve the correct information, we need to integrate the mobile-mvi-service as a dependency in our service. Since this service contacts the VA’s master veteran index (MVI) service, we will need to mock out a response from this service using wiremock.

To add mobile-mvi-service to your project, you’ll need to make the following modifications:

  • Add mobile-mvi-client.version as a property in the service’s pom.xml

  • Add mobile-mvi-client as a provided dependency to pom.xml

  • Add wiremock-httpclient-apache5 as a test dependency to pom.xml

  • Add the wiremock service as a dependency to the service’s kubernetes configuration for use during testing

  • Update the application.properties file to add the Rest configuration for the mobile-mvi-service base URL

  • Update the application-dev.properties file to override the mobile-mvi-service base URL to point to the wiremock service

  • Add a port forward for wiremock to skaffold.yaml

  • Add the mobile-mvi-service dependency to the list of dependencies in the README.md

To start, add the following property and dependencies to starter-service’s pom.xml in the appropriate sections:

<properties>
...
    <mobile-mvi-client.version>1.49</mobile-mvi-client.version>
...
</properties>

<dependencies>
...
    <!-- Clients -->
    <dependency>
        <groupId>gov.va.mobile.client.openapi</groupId>
        <artifactId>mobile-mvi-client</artifactId>
        <version>${mobile-mvi-client.version}</version>
    </dependency>
...
    <!-- Test Dependencies -->
    <dependency>
        <groupId>org.wiremock</groupId>
        <artifactId>wiremock-httpclient-apache5</artifactId>
        <scope>test</scope>
    </dependency>
...
</dependencies>

Next, add wiremock as a service dependency in the kubernetes/dev-with-dependencies/kustomization.yaml file. This will pull the wiremock service from its git repository and configure it for use with skaffold and kustomize:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
- https://coderepo.mobilehealth.va.gov/scm/ckm/common-app-config.git//dev?ref=main


components:
- https://coderepo.mobilehealth.va.gov/scm/ckm/wiremock.git//kubernetes?ref=Release/3.13.2&timeout=60s
- ../components/dev

Add the following property to starter-service/src/main/resources/application.properties. This property allows Mobile Framework’s RestClientManager to configure a RestClient for the mobile-mvi-service with the appropriate base URL:

va.mobile.restclient.registration.mobile-mvi-service.url=http://mobile-mvi-service-v1:8080/mvi/v1

Now, override the same property in starter-service/src/main/resources/application-dev.properties to point to the wiremock service in your development environment. This will allow the service to use the wiremock service instead of the actual mobile-mvi-service during development and testing:

va.mobile.restclient.registration.mobile-mvi-service.url=http://wiremock:8080/mvi/v1

Lastly, add a port forward for wiremock to skaffold.yaml in order for it to be available to our integration tests:

...
portForward:
- resourceType: service
  resourceName: starter-service-v1
  port: 8080
- resourceType: deployment
  resourceName: starter-service-v1
  port: 8081
- resourceType: deployment
  resourceName: starter-service-v1
  port: 6300
- resourceType: service
  resourceName: wiremock
  port: 8080
  localPort: 8084
...

That’s all the configuration changes needed to add the mobile-mvi-service and wiremock dependencies to your service. Since you will be mocking responses from mobile-mvi-service, it is not necessary to add it as a running service in your cluster. If you run the service at this point using skaffold dev, you will see starter-service and wiremock initialized as docker containers once startup completes by executing a docker ps:

CONTAINER ID   IMAGE          COMMAND                  CREATED          STATUS          PORTS     NAMES
ee8adb88c7fe   cb94f7a4f456   "/docker-entrypoint.…"   39 seconds ago   Up 38 seconds             k8s_wiremock_wiremock-7459bc54dc-k2n5d_starter-service-test_6e207ac7-f3b9-443c-9737-f086448ea5f8_0
ab3a2130a37f   6e9805aff57e   "java -javaagent:/ap…"   39 seconds ago   Up 38 seconds             k8s_starter-service_starter-service-v1-6bd7fb5b7-m6d45_starter-service-test_5915f353-8991-4be1-bbaf-e224862aa207_0

Modify the Patient Info Endpoint

Now that the configuration is updated, we can get details about a patient via their ICN number from the mobile-mvi-service client instead of reading it from the logged-in user JWT. To do this, we will create a client wrapper class to interact with the mobile-mvi-service, and then call its getVeteranDemographics method to get a Person object. The data from this Person object will then be set on a PatientInfoResponse instance and returned.

Create a new package at the same level as controller named client, and within that package create a new Java class named MviClient. This class will be responsible for interacting with the mobile-mvi-service RestClient to retrieve patient information. Copy the following code into the MviClient.java class:

package gov.va.mobile.starter.v1.service.client;

import gov.va.mobile.boot.restclient.RestClientManager;
import gov.va.mobile.mvi.v1.model.Person;
import gov.va.mobile.security.HeaderNames;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClient;

import static gov.va.mobile.Sanitizer.sanitize;

/**
 * A client wrapper for the Mobile MVI Service.
 */
@Slf4j
@Component
public class MviClient {

    private static final String SENDER_ID = "200CIH";

    private final RestClient restClient;

    /**
     * Constructor for MviClient.
     *
     * @param restClientManager the RestClientManager used to obtain a RestClient for the Mobile MVI Service
     */
    public MviClient(final RestClientManager restClientManager) {
        restClient = restClientManager.getClient("mobile-mvi-service");
    }

    /**
     * Retrieves Patient Person demographic data from MVI by ICN. The authorized JWT is propagated to Mobile MVI Service.
     * <p>
     * The underlying query is a 1305 request.
     *
     * @param icn ICN of the veteran to find identifiers for.
     * @param jwt JWT to allow access to mobile mvi service
     * @return the Person model for the patient
     */
    public @Nullable Person getVeteranDemographics(final String icn, final String jwt) {

        try {
            return restClient.get()
                             .uri("/patients/ICN/{id}/mvi/person?senderId={senderId}", icn, SENDER_ID)
                             .header(HeaderNames.VAMF_JWT_HEADER, jwt)
                             .retrieve()
                             .body(Person.class);
        } catch (HttpClientErrorException.NotFound | HttpServerErrorException.InternalServerError e) {
            // mobile-mvi-service using wiremock will respond with a 500 for non-existent patients, so we add
            // InternalServerError to this catch block to handle that test condition
            final String cleanIcn = sanitize(icn);
            log.error("Could not fetch demographics for ICN {}", cleanIcn);
            return null;
        }
    }
}

Note that the constructor for this class takes in a RestClientManager, which is used to obtain a RestClient instance that is configured to interact with the mobile-mvi-service based on the property we added to application.properties earlier. This RestClient is used by the getVeteranDemographics method to call to the mobile-mvi-service to retrieve demographic information about the patient associated with the provided ICN. The passed in JWT is propagated in the call to the mobile-mvi-service to allow for proper authentication and authorization when accessing the service.

Now, update ServiceController.java to resemble the code below. Make note of the following changes:

  • The addition of an MviClient instance, which is wired into the service via the added @AllArgsConstructor annotation

  • Update to the getInfo method signature to include the JWT from the x-vamf-jwt header, which is then passed into the getVeteranDemographics method of the MviClient instance to retrieve the Person object for the provided ICN

  • Logic updates to use the Person object returned from the MviClient to populate the PatientInfoResponse object that is returned to the caller. If no Person object is found, a 404 Not Found response is returned.

package gov.va.mobile.starter.v1.service.controller;

import gov.va.mobile.mvi.v1.model.Person;
import gov.va.mobile.security.HeaderNames;
import gov.va.mobile.security.web.jwt.JwtRestricted;
import gov.va.mobile.starter.v1.model.PatientInfo;
import gov.va.mobile.starter.v1.model.PatientInfoResponse;
import gov.va.mobile.starter.v1.service.client.MviClient;
import lombok.AllArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * REST endpoint that allows clients to retrieve Patient Information.
 *
 * @since 1.0
 */
@RestController
@AllArgsConstructor
@JwtRestricted(checkResource = true)
@RequestMapping(path = "/patients/{icn}")
public class ServiceController {

    /**
     * RestClient instance for interacting with the Mobile MVI Service.
     */
    private final MviClient mviClient;

    /**
     * Returns the Patient Information {@link PatientInfoResponse} associated with the provided ICN Identifier.
     *
     * @param icn
     *         the ICN Identifier used to retrieve the {@link PatientInfoResponse}
     * @param jwt
     *         the JWT from the x-vamf-jwt header.
     *
     * @return ResponseEntity wrapped with PatientInfoResponse {@link PatientInfoResponse}
     *
     * @see PatientInfoResponse
     */
    @GetMapping(value = "/info", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<PatientInfoResponse> getInfo(@PathVariable("icn") final String icn,
                                                       @RequestHeader(HeaderNames.VAMF_JWT_HEADER) final String jwt) {

        final Person person = mviClient.getVeteranDemographics(icn, jwt);
        if (person == null) {
            return ResponseEntity.notFound().build();
        }

        final PatientInfo info = new PatientInfo();
        info.setFirstName(person.getFirstName());
        info.setLastName(person.getLastName());
        info.setDateOfBirth(person.getBirthTime());
        info.setIcn(icn);
        final PatientInfoResponse response = new PatientInfoResponse();
        response.setData(info);

        return ResponseEntity.ok(response);
    }
}

Load the Mock MVI Data for Testing

Because we need to mock a response from mobile-mvi-service in order to test this code, we need to load some mock MVI data into our wiremock registry.

To load the mock data and make it available for testing, update BaseITCase.java as below to create a WireMock client and use it to load mock data for the veteran demographics endpoint called by your MviClient, as well as a mock health check response for mobile-mvi-service.

package gov.va.mobile.starter.v1.service;

import com.fasterxml.jackson.databind.json.JsonMapper;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.common.ContentTypes;
import gov.va.mobile.mvi.v1.model.Person;
import gov.va.mobile.tools.skaffold.SkaffoldTestSupport;
import org.junit.jupiter.api.BeforeAll;
import org.springframework.http.HttpHeaders;

import java.io.IOException;

import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.ok;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;

/**
 * Base Class for the starter-service Integration Test Cases.
 *
 * @since 1.0
 */
public abstract class BaseITCase {

    /**
     * Veteran ICN for testing.
     */
    protected static final String VET_ICN = "100031V310296";

    /**
     * Sender ID for testing.
     */
    private static final String SENDER_ID = "200CIH";

    /**
     * JSON Mapper for serializing and deserializing JSON.
     */
    private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build();

    /**
     * The endpoint url for the service endpoints.
     *
     * @return the endpoint url
     */
    protected static String getServiceUrl() {
        return SkaffoldTestSupport.getServiceUrl("starter-service-v1", 8080, "/starter/v1");
    }

    /**
     * The endpoint url for the service actuator endpoints.
     *
     * @return the endpoint url
     */
    protected static String getActuatorUrl() {
        return SkaffoldTestSupport.getServiceUrl("starter-service-v1", 8081);
    }

    /**
     * Initialize WireMock stubs for MVI before all tests.
     *
     * @throws Exception if an error occurs during initialization
     */
    @BeforeAll
    public static void init() throws Exception {
        final WireMock wireMock = WireMock
                .create().host(SkaffoldTestSupport.getHost("wiremock"))
                .port(SkaffoldTestSupport.getPort("wiremock", 8080))
                .build();

        mockVeteranDemographics(wireMock, VET_ICN, SENDER_ID, "John", "Doe");
        mockSuccessfulHealthCheck(wireMock);
    }

    /**
     * Mocks the veteran demographics response for a given ICN and sender ID.
     *
     * @param wireMock the wiremock client
     * @param icn the ICN of the veteran
     * @param senderId the sender ID
     * @param firstName the first name of the veteran
     * @param lastName the last name of the veteran
     * @throws IOException if an I/O error occurs
     */
    public static void mockVeteranDemographics(final WireMock wireMock, final String icn, final String senderId, final String firstName, final String lastName) throws IOException {
        final Person person = new Person();
        person.setFirstName(firstName);
        person.setLastName(lastName);
        wireMock.register(
                get(urlEqualTo("/mvi/v1/patients/ICN/%s/mvi/person?senderId=%s".formatted(icn, senderId)))
                        .willReturn(ok(JSON_MAPPER.writeValueAsString(person))
                                            .withHeader(HttpHeaders.CONTENT_TYPE, ContentTypes.APPLICATION_JSON)));
    }

    /**
     * Registers MVI health check mock.
     *
     * @param wireMock
     *         the wiremock client
     */
    public static void mockSuccessfulHealthCheck(final WireMock wireMock) {
        wireMock.register(get(urlEqualTo("/mvi/v1/version")).willReturn(ok().withBody("mockMviSvcV1")));
    }
}

Modify Tests for Patient Info Endpoint

Since you have updated the code in ServiceController.java, the example test in ServiceControllerITCase.java is now incorrect and needs to be updated. Below is an example implementation of the getPatientInfo test for a patient (veteran) user.

package gov.va.mobile.starter.v1.service;

import gov.va.mobile.boot.restclient.test.RestClientUtils;
import gov.va.mobile.security.HeaderNames;
import gov.va.mobile.security.jwt.test.JwtTestUtils;
import gov.va.mobile.security.jwt.v2.JwtUserV2;
import gov.va.mobile.starter.v1.model.PatientInfoResponse;
import org.junit.jupiter.api.Test;
import org.springframework.web.client.RestClient;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * Integration Test cases for the {@code "/patients/{icn}/info"} REST endpoint.
 *
 * @since 1.0
 */
class ServiceControllerITCase extends BaseITCase {

    /**
     * 200 response - Patient is able to get self data.
     */
    @Test
    void testGetPatientInfoAsVeteranUser() {
        final JwtUserV2 user = JwtTestUtils.V2
                .createVeteranUserBuilder()
                .authorizedResources(List.of(String.format("/patients/%s/info", VET_ICN)))
                .build();

        final RestClient restClient = buildRestClient(user);

        final PatientInfoResponse response = restClient
                .get()
                .uri("/patients/{icn}/info", VET_ICN)
                .retrieve()
                .body(PatientInfoResponse.class);

        assertThat(response).isNotNull();
        assertThat(response.getData()).isNotNull();
        assertThat(response.getData().getFirstName()).isEqualTo("John");
        assertThat(response.getData().getLastName()).isEqualTo("Doe");
    }

    /**
     * 200 response - Staff is able to get patient data.
     */
    @Test
    void testGetPatientInfoAsStaffUser() {
        //TODO: implement
    }

    /**
     * 401 response - no JWT is present.
     */
    @Test
    void testFailWithoutJWT() {
        //TODO: implement
    }

    /**
     * 403 response - no permissions.
     */
    @Test
    void testFailWithNoPermissions() {
        //TODO: implement
    }

    /**
     * 404 response - person not found.
     */
    @Test
    void testFailWithPersonNotFound() {
        //TODO: implement
    }

    private static RestClient buildRestClient(final JwtUserV2 user) {
        return RestClientUtils.createBuilder()
                              .baseUrl(getServiceUrl())
                              .defaultHeader(HeaderNames.VAMF_JWT_HEADER, JwtTestUtils.generateJwt(user))
                              .build();
    }
}

Go ahead and execute a full build of the service with ./mvnw clean verify -Pwith-skaffold to make sure the new code and tests compile. However, since we added logic to the ServiceController, you should see a failure at the end of your build due to unmet code coverage checks. We will address that next.

Implement Integration Tests

In order to get a successful build, you need to implement the integration tests outlined in the code above:

  • Test for a staff user able to access the test veteran’s patient info

  • Test for no JWT present (you will need to create a RestClient without the JWT header for this test)

  • Test for no permissions (this endpoint is resource restricted)

  • Test for a 404 response when the person is not found in MVI (use a non-existent ICN for this test)

As you implement these tests, be mindful of opportunities to reuse code to avoid duplication. The buildRestClient method is a good example; it’s generally useful, so take this opportunity to move it over to your BaseITCase class for other test classes to use moving forward. Also explore the builders and helpers available in the JwtTestUtils and RestClientUtils classes to help with generating the JWTs and RestClient instances needed for the tests.

Once you have implemented the tests, run a full build of the service again with ./mvnw clean verify -Pwith-skaffold to make sure your coverage checks are met and the build is successful.

If you have failing integration tests or need help understanding your test’s behavior/execution, you can set up a Remote JVM Debugger in your IDE as detailed in the debug guide. Then you can start the service with skaffold debug and run the integration tests from your IDE with breakpoints set in your application code to inspect the behavior and debug any issues.
Because the /patients/{icn}/info endpoint is mocked, if you bring up your service now with skaffold dev and try to hit this endpoint in Postman or via curl, you will receive a 500 error, as the mocks are only initialized during the integration test phase.

Testing Ideals

Our testing philosophy combines unit and integration testing to verify the amount of coverage we have around our endpoints. When considered holistically like this, we are able to value test coverage as a meaningful metric regarding the state of our services. Therefore, we attempt to use real services as much as possible, using mocks only when necessary, and often only when retrieving data from external systems, as is the case in our starter-service. Unit tests are secondary to integration tests, and are usually only needed for code that is difficult to cover in integration tests.

Jacoco Coverage Report

To help you visualize your test coverage, you can generate the Jacoco coverage report by executing a full maven build with the verify or install goals:

./mvnw clean verify -Pwith-skaffold

or

./mvnw clean install -Pwith-skaffold

This will generate a report at target/site/jacoco-aggregate/index.html that you can open in a browser to see the coverage details.

Implement The New User Session Endpoints

Next, we return to the two stubbed-out endpoints we created earlier in UserSessionController.java.

  • The PUT endpoint should use user-session-service and its generated client to save a sanitized user input (key/value pair) into a session variable. This endpoint should be restricted to only Staff users. The term we use for this type of access control is RBAC (Role-Based Access Control).

  • The GET endpoint should return the session value for the requested key, and should be restricted to only those who have permission to access that specific endpoint. This is known as Resource-Based Access Control. Although the acronym RBAC is suitable for each, this type of control is simplified just as “resource” control.

In order to add user-session-service to your project, the following steps will need to be taken. Note that we are going to deploy this service to your cluster and interact with it, rather than mocking it out.

  • Add session-service-client.version as a build property and session-service-client as a client dependency to the pom.xml.

  • Add the redis and user-session-service dependencies to the service’s kubernetes configuration. User Session Service depends on Redis, so we need to add both of these services to our cluster in order for the user-session-service to function properly.

  • Add the required property for the user-session-service base URL to application.properties.

  • Add the user-session-service dependency to the list of dependencies in the README.md

Following the steps above, add the following to the pom.xml file in the appropriate sections:

<properties>
...
  <session-service-client.version>1.32</session-service-client.version>
...
</properties>

<dependencies>
...
<dependency>
  <groupId>gov.va.mobile.client.openapi</groupId>
  <artifactId>session-service-client</artifactId>
  <version>${session-service-client.version}</version>
</dependency>
...
</dependencies>

Add the following to the kubernetes/dev-with-dependencies/kustomization.yaml file in the appropriate section:

...
components:
- https://coderepo.mobilehealth.va.gov/scm/dhsss/redis.git//kubernetes?ref=v7.2.12&timeout=60s
- https://coderepo.mobilehealth.va.gov/scm/iums/user-session-service.git//kubernetes/components/dev?ref=Release/1.32&timeout=60s
...

Lastly, add the RestClient configuration for the user-session-service to application.properties:

va.mobile.restclient.registration.user-session-service.url=http://user-session-service-v1:8080/session/v1

Now that user-session-service is wired in, we can make some code updates. To interact with the user-session-service, we will again create a client wrapper class named SessionClient in the gov.va.mobile.starter.v1.service.client package, similar to the MviClient we created earlier.

package gov.va.mobile.starter.v1.service.client;

import gov.va.mobile.boot.restclient.RestClientManager;
import gov.va.mobile.security.HeaderNames;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClient;

import static gov.va.mobile.Sanitizer.sanitize;

/**
 * A client wrapper for the User Session Service.
 */
@Slf4j
@Component
public class SessionClient {

    private final RestClient restClient;

    /**
     * Constructor for SessionClient.
     *
     * @param restClientManager the RestClientManager used to obtain a RestClient for the User Session Service
     */
    public SessionClient(final RestClientManager restClientManager) {
        restClient = restClientManager.getClient("user-session-service");
    }

    /**
     * Retrieves session value from user-session-service.
     *
     * @param key the key to retrieve the session value for.
     * @param jwt the JWT to allow access to user session service
     * @return the session value for the provided key.
     * @throws HttpClientErrorException if value is not found
     */
    public @Nullable String getSessionValue(final String key, final String jwt) {

        return restClient.get()
                         .uri("/values/{key}", key)
                         .header(HeaderNames.VAMF_JWT_HEADER, jwt)
                         .retrieve()
                         .body(String.class);
    }

    /**
     * Sets a session value in the User Session Service for a specified key.
     *
     * @param key the session key under which to store the value; must not be null
     * @param value the session value to store; must not be null
     * @param jwt the JWT to allow access to user session service
     * @throws NullPointerException if key or value is null
     * @throws org.springframework.web.client.RestClientException if an HTTP error occurs
     *     during the request (e.g., connection timeout, service unavailable)
     * */
    public void putSessionValue(final String key, final String value, final String jwt) {

        try {
            restClient.put()
                      .uri("/values/{key}", key)
                      .header(HeaderNames.VAMF_JWT_HEADER, jwt)
                      .body(value)
                      .retrieve()
                      .toBodilessEntity();
        } catch (HttpClientErrorException | HttpServerErrorException.InternalServerError e) {
            final String cleanKey = sanitize(key);
            log.error("Could not set value for key {}", cleanKey);
        }
    }
}

Next, we need to update our stubbed out methods from earlier in the UserSessionController class. There are a few things we are adding so that our user-session-service is ready to go:

  • Declare an instance of our new SessionClient, wired in via constructor injection.

  • Add the x-vamf-jwt request header as a method parameter for the getSessionValue and putSessionValue methods.

  • Add the request body as a method argument for putSessionValue. The class we’ll use to do this is SetSessionValueRequest, which is generated from the swagger contract and is in our client model.

  • In the methods, we will pass the JWT of the caller to the SessionClient calls, which will then pass it along to the user-session-service to ensure the calls are authenticated and authorized correctly.

  • To enforce the access control criteria stated earlier, we’ll add the JwtRestricted annotation to the class (since the endpoint is the same for both PUT and GET) for resource restriction, and we’ll add an additional "Staff" role restriction on the putSessionValue method in order to further restrict access to Staff users only.

Here is what your UserSessionController class should look like:

package gov.va.mobile.starter.v1.service.controller;

import gov.va.mobile.security.HeaderNames;
import gov.va.mobile.security.UserRoles;
import gov.va.mobile.security.web.jwt.JwtRestricted;
import gov.va.mobile.starter.v1.model.SetSessionValueRequest;
import gov.va.mobile.starter.v1.service.client.SessionClient;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * REST endpoint that allows clients to store and retrieve session values.
 *
 * @since 1.0
 */
@RestController
@JwtRestricted(checkResource = true)
@AllArgsConstructor
@RequestMapping(path = "/session/values/{key}")
public class UserSessionController {

    private final SessionClient sessionClient;

    /**
     * Get Session value that matches the key.
     *
     * @param key Key to be matched
     * @return ResponseEntity with session value for given key
     */
    @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<String> getSessionValue(@PathVariable final String key,
                                                  @RequestHeader(HeaderNames.VAMF_JWT_HEADER) final String jwt) {
        final String value = sessionClient.getSessionValue(key, jwt);
        return ResponseEntity.status(HttpStatus.OK).body(value);
    }

    /**
     * Sets a Session value given a key and a JSON Value Object.
     *
     * @param key Key to be used for the Session. Sets the index to the value
     * @return ResponseEntity with status OK if successful
     * @see SetSessionValueRequest JSON object contains the value to be set for the Session
     */
    @PutMapping
    @JwtRestricted(roles = UserRoles.STAFF, checkResource = true)
    public ResponseEntity<Void> putSessionValue(@PathVariable final String key,
                                                @RequestBody final SetSessionValueRequest body,
                                                @RequestHeader(HeaderNames.VAMF_JWT_HEADER) final String jwt) {
        final String value = body.getValue();
        if (value == null) {
            return ResponseEntity.badRequest().build();
        }
        sessionClient.putSessionValue(key, value, jwt);
        return ResponseEntity.status(HttpStatus.OK).build();
    }
}

With this class in place we can start up our service using the skaffold dev command and make calls to both set and retrieve session variables. These calls are JWT protected in the user-session-service, hence the reason we pass the JWT to the SessionClient when making any calls to it. For PUT calls, make sure you add the request body (see screenshot).

postman session put

postman session get

If you execute a docker ps once startup completes, you would see all the services initialized as docker containers:

CONTAINER ID   IMAGE          COMMAND                  CREATED         STATUS         PORTS     NAMES
bc1225692869   cb94f7a4f456   "/docker-entrypoint.…"   6 minutes ago   Up 6 minutes             k8s_wiremock_wiremock-7b96f79bdf-j2h4z_starter-service-test_de092098-4342-404c-826b-318539b1278e_0
21db30f984e2   7cb68f548201   "java -javaagent:/ap…"   6 minutes ago   Up 6 minutes             k8s_starter-service_starter-service-v1-6b54dffcff-nvvsq_starter-service-test_bc982cb1-7500-45e1-b7c6-46254ba6aa3e_0
c34aff8a0b71   d34dc1f0cffd   "java -cp @/app/jib-…"   6 minutes ago   Up 6 minutes             k8s_user-session-service_user-session-service-v1-5dfff9c9d8-lnqd4_starter-service-test_cb1bce24-4ba3-4178-86cd-8f9afa41afa4_0
a0245f78f309   061e6c3fb2a2   "sh -c 'cp /init/dum…"   6 minutes ago   Up 6 minutes             k8s_redis-v7_redis-v7-8db5d5749-94s69_starter-service-test_8e0b7c83-ff40-433e-b472-ab447c8e5b46_0

Create Integration Tests For User Session Endpoints

At this point, you should create some integration tests for your UserSessionController.java class. Create your test class in the same test directory where your ServiceControllerITCase test file is located and name it UserSessionControllerITCase.java.

  • The example below contains successful PUT and GET tests, as well as a negative PUT test. Helper methods have been provided for making the Rest calls to the endpoints.

    The example tests reference buildRestClient, the helper method you refactored from ServiceControllerITCase to BaseITCase earlier. If you have not done so, perform that refactoring now so it can be reused in this test class.
  • Implement the additional negative case tests for the outlined scenarios.

package gov.va.mobile.starter.v1.service;

import gov.va.mobile.security.jwt.test.JwtTestUtils;
import gov.va.mobile.security.jwt.v2.JwtUserV2;
import gov.va.mobile.starter.v1.model.SetSessionValueRequest;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;

import java.util.Collections;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

class UserSessionControllerITCase extends BaseITCase {

    final String testValue = "1234Test";
    final SetSessionValueRequest sessionValueRequest = new SetSessionValueRequest();

    UserSessionControllerITCase() {
        sessionValueRequest.setValue(testValue);
    }

    /**
     * Set and Get Key, 200.
     */
    @Test
    void testPutSessionValueAndGet() {
        final JwtUserV2 user = JwtTestUtils.V2
                .createStaffUserBuilder()
                .build();

        final RestClient restClient = buildRestClient(user);

        restPutSessionValue(restClient, "test", sessionValueRequest);

        final String val = restGetSessionValue(restClient, "test");
        assertThat(val).isEqualTo(testValue);
    }

    /**
     * Set Without Resource, 403.
     */
    @Test
    void testSetSessionWithJwtWithoutAuthorizedResources() {
        final JwtUserV2 user = JwtTestUtils.V2
                .createStaffUserBuilder()
                .authorizedResources(Collections.emptyList())
                .build();

        final RestClient restClient = buildRestClient(user);

        final HttpClientErrorException thrown = assertThrows(HttpClientErrorException.class, () -> restPutSessionValue(restClient, "test", sessionValueRequest));
        Assertions.assertThat(thrown.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
    }

    /**
     * Set Without Staff Role, 403.
     */
    @Test
    void testSetSessionWithJwtWithoutStaffRole() {
        //TODO: implement
    }

    /**
     * Set Key Without JWT, 401.
     */
    @Test
    void testSetSessionWithoutJwt() {
        //TODO: implement
    }

    /**
     * Get Without Resource, 403.
     */
    @Test
    void testGetSessionWithJwtWithoutAuthorizedResources() {
        //TODO: implement
    }

    /**
     * Get Key Without JWT, 401.
     */
    @Test
    void testGetSessionWithoutJwt() {
        //TODO: implement
    }

    /**
     * Set Key With Null Value, 400.
     */
    @Test
    void testSetSessionWithNullValue() {
        //TODO: implement
    }

    /**
     * NOTE: This test is a special case.
     * Get Key Not Found, 404.
     */
    @Test
    void testNotFoundKey() {
        //TODO: implement
    }

    private static String restGetSessionValue(final RestClient restClient, final String key) {
        return restClient.get()
                         .uri("/session/values/{key}", key)
                         .retrieve()
                         .toEntity(String.class)
                         .getBody();
    }

    private static void restPutSessionValue(final RestClient restClient, final String key, final SetSessionValueRequest sessionValueRequest) {
        restClient.put()
                  .uri("/session/values/{key}", key)
                  .body(sessionValueRequest)
                  .retrieve()
                  .toBodilessEntity();
    }
}
The integration test testNotFoundKey() has a note above it stating that it is a special case. Since your service does not have custom exception handling yet, a search for a non-existent key will result in a 500 Internal Server Error rather than a 404 Not Found Error. This behavior will change once you add exception handling, but for now, implement testNotFoundKey() to test for a 500 Internal Server Error.

Update JWTVerificationITCase With New Endpoint

One of the tests that was provided for you when you created your service is JWTVerificationITCase. This test provides basic JWT resource and/or role requirement testing for service endpoints. The provided implementation already accounts for resource testing of the endpoint in ServiceController. However, as implemented, it does not account for role testing.

  • Create new entries in the jwtRestRequests helper method for the /session/values/{key} GET and PUT mappings (the key can be any string) to verify they are resource protected.

    • The commented out entry shows an example of specifying a different HTTP method (the default is GET).

  • Additionally, create a new parameterized test for role testing called testJwtRoleRestrictions. Refer to the existing testJwtRestrictions test for an example of how to implement this for role testing instead of resource testing.

    • The method signature should take in a JwtRestRequest and one or more String roles to verify.

    • Create jwtRestRoleRequests helper method to return testing arguments like in jwtRestRequests, but including the role(s) for role testing. The testJwtRoleRestrictions test should use this helper as a method source to verify that the /session/values/{key} PUT endpoint is restricted to Staff users only.

Add Service Health Checks to CommonServiceITCase

Your generated service also includes a test class called CommonServiceITCase, which includes a test called verifyHealthChecks. This test utilizes ActuatorAssertions from the mobile-boot-starter-webmvc-test library dependency included in your service, and it will verify a service’s availability through its /health Actuator endpoint. Your added dependencies for the Mobile MVI and User Session services are configured using Mobile Framework’s RestClientManager, which automatically configures their health endpoints. You can add them to this test class so that their availability can be considered as part of your service’s overall health.

  • Update the verifyHealthChecks test of CommonServiceITCase by uncommenting the .hasComponentsUp() directive and adding mobile-mvi-service and user-session-service as included dependent services as part of the service health check.


If your service is currently running, run ./mvnw verify -Pskaffold-integration to verify that your new and modified tests execute successfully. Or, if your service is not running, execute a full build, including integration tests, with ./mvnw clean install -Pwith-skaffold. If you have any test failures, resolve those before moving on to the next section. If you have issues with your tests and need to debug, refer to the debug guide for instructions on setting up remote debugging with your IDE. If you have additional questions, please reach out to the onboarding team for assistance.