diff --git a/build.gradle b/build.gradle index a629b2c..4855df2 100644 --- a/build.gradle +++ b/build.gradle @@ -242,12 +242,45 @@ tasks.register("openApiGenerateProjectInfoServicesClient", GenerateTask) { ] } +tasks.register("openApiGenerateFeedbackApiClient", GenerateTask) { + generatorName = "java" + outputDir = "$projectDir/target/generated-sources/openapi-client-feedback-api" + inputSpec = "$projectDir/openapi/openapi-feedback-api-v0.0.1.yaml" + library = "resttemplate" + + globalProperties = [ + generateAliasAsModel: "true", + apiDocs : "true", + modelDocs : "true", + apiTests : "false", + modelTests : "false" + ] + + additionalProperties = [ + serializableModel : "true", + useJakartaEe : "true", + java8 : "true", + groupId : "feedback", + artifactId : "feedback-api-v0-client", + artifactVersion : "0.0.0-SNAPSHOT", + invokerPackage : "org.opendevstack.component_catalog.client.feedback_api.v0_0_1", + apiPackage : "org.opendevstack.component_catalog.client.feedback_api.v0_0_1.api", + modelPackage : "org.opendevstack.component_catalog.client.feedback_api.v0_0_1.model", + additionalModelTypeAnnotations : "@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)", + generateBuilders : "true", + generateClientAsBean : "true", + useSingleRequestParameter : "true", + hideGenerationTimestamp : "true" + ] +} + // Ensure code generation runs before Java compilation tasks.named("compileJava") { dependsOn( tasks.named("openApiGenerate"), tasks.named("openApiGenerateBitbucketClient"), - tasks.named("openApiGenerateProjectInfoServicesClient") + tasks.named("openApiGenerateProjectInfoServicesClient"), + tasks.named("openApiGenerateFeedbackApiClient") ) } @@ -264,7 +297,8 @@ sourceSets { "src/main/java", "$projectDir/target/generated-sources/openapi-server-component_catalog/src/main/java", "$projectDir/target/generated-sources/openapi-client-bitbucket/src/main/java", - "$projectDir/target/generated-sources/openapi-client-projects-info-service/src/main/java" + "$projectDir/target/generated-sources/openapi-client-projects-info-service/src/main/java", + "$projectDir/target/generated-sources/openapi-client-feedback-api/src/main/java" ] } resources { diff --git a/openapi/openapi-component_catalog-v1.0.0.yaml b/openapi/openapi-component_catalog-v1.0.0.yaml index 47d2239..9c56981 100644 --- a/openapi/openapi-component_catalog-v1.0.0.yaml +++ b/openapi/openapi-component_catalog-v1.0.0.yaml @@ -36,6 +36,8 @@ tags: description: Provisioning notifications from AWX/Provisioner - name: CacheAdministration description: Internal cache administration operations. + - name: FeedbackSubmissions + description: Feedback submissions operations. paths: /project/components: get: @@ -1167,6 +1169,50 @@ paths: "500": description: Server error. + /feedback/submissions: + post: + tags: + - FeedbackSubmissions + summary: Register a feedback submission + operationId: registerFeedbackSubmission + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FeedbackSubmissionRequest' + responses: + '201': + description: Feedback submission created + content: + application/json: + schema: + $ref: '#/components/schemas/FeedbackSubmission' + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + '401': + description: Invalid client token or unregistered client application + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + '403': + description: Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + components: securitySchemes: bearerAuth: @@ -1662,6 +1708,65 @@ components: type: string required: - message + FeedbackSubmissionRequest: + type: object + required: + - questions + - answers + - submitter + properties: + projectKey: + type: string + minLength: 1 + description: Optional project key; omit to submit feedback without a project association. + submitter: + type: string + format: email + minLength: 1 + description: Email address of the person submitting feedback. + questions: + type: array + items: + type: object + additionalProperties: true + description: Question definitions shown to the submitter. + answers: + type: object + additionalProperties: true + description: Answers and possible follow-ups submitted. + FeedbackSubmission: + type: object + required: + - clientAppRegistrationId + - clientName + - submittedAt + - questions + - answers + properties: + clientAppRegistrationId: + type: string + format: uuid + description: Azure application registration identifier from the caller token. + clientName: + type: string + projectKey: + type: string + nullable: true + description: Project key, null when no project was supplied. + submitter: + type: string + description: Optional submitter identity. + submittedAt: + type: string + format: date-time + questions: + type: array + items: + type: object + additionalProperties: true + answers: + type: object + additionalProperties: true ValidationResult: type: object properties: diff --git a/openapi/openapi-feedback-api-v0.0.1.yaml b/openapi/openapi-feedback-api-v0.0.1.yaml new file mode 100644 index 0000000..e4c8b87 --- /dev/null +++ b/openapi/openapi-feedback-api-v0.0.1.yaml @@ -0,0 +1,246 @@ +openapi: 3.0.3 +info: + title: Feedback API + description: API documentation for feedback submissions + contact: + name: ODS Team + version: v0.0.1 +servers: + - url: http://{baseurl}/api/v1/feedback + variables: + baseurl: + default: localhost:8080 + description: Development environment +security: + - bearerAuth: [] +tags: + - name: Feedback submissions + description: API for managing feedback submissions +paths: + /submissions: + post: + tags: + - Feedback submissions + summary: Register a feedback submission + operationId: registerFeedbackSubmission + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FeedbackSubmissionRequest' + responses: + '201': + description: Feedback submission created + content: + application/json: + schema: + $ref: '#/components/schemas/FeedbackSubmission' + '400': + $ref: '#/components/responses/BadRequestResponse' + '401': + $ref: '#/components/responses/UnauthorizedResponse' + '403': + $ref: '#/components/responses/ForbiddenResponse' + '500': + $ref: '#/components/responses/InternalErrorResponse' + /submissions/{client_app_registration_id}: + get: + tags: + - Feedback submissions + summary: Get feedback submissions for a client application + operationId: getFeedbackSubmissionsByClientAppRegistrationId + parameters: + - name: client_app_registration_id + in: path + required: true + schema: + type: string + format: uuid + description: Client application registration ID used to filter submissions. + - name: startDate + in: query + required: false + schema: + type: string + format: date-time + description: Return submissions created at or after this timestamp. + - name: endDate + in: query + required: false + schema: + type: string + format: date-time + description: Return submissions created at or before this timestamp. + - name: page + in: query + required: false + schema: + type: integer + default: 0 + minimum: 0 + description: Zero-based page number to retrieve. + - name: size + in: query + required: false + schema: + type: integer + default: 20 + minimum: 1 + maximum: 100 + description: Number of submissions per page. + responses: + '200': + description: A page of feedback submissions for the client application. + content: + application/json: + schema: + $ref: '#/components/schemas/GetFeedbackSubmissionsResponse' + '400': + $ref: '#/components/responses/BadRequestResponse' + '401': + $ref: '#/components/responses/UnauthorizedResponse' + '403': + $ref: '#/components/responses/ForbiddenResponse' + '404': + description: The requested page does not exist. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + '500': + $ref: '#/components/responses/InternalErrorResponse' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + responses: + BadRequestResponse: + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + UnauthorizedResponse: + description: Invalid client token or unregistered client application + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + ForbiddenResponse: + description: Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + InternalErrorResponse: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + schemas: + RestErrorMessage: + type: object + required: + - message + properties: + message: + type: string + FeedbackSubmissionRequest: + type: object + required: + - questions + - answers + - submitter + properties: + projectKey: + type: string + minLength: 1 + maxLength: 100 + description: Optional project key; omit to submit feedback without a project association. + submitter: + type: string + format: email + minLength: 1 + maxLength: 255 + description: Email address of the person submitting feedback. + questions: + type: array + items: + type: object + additionalProperties: true + description: Question definitions shown to the submitter. + answers: + type: object + additionalProperties: true + description: Answers and possible follow-ups submitted. + FeedbackSubmission: + type: object + required: + - clientAppRegistrationId + - clientName + - submittedAt + - questions + - answers + properties: + clientAppRegistrationId: + type: string + format: uuid + description: Azure application registration identifier from the caller token. + clientName: + type: string + maxLength: 255 + projectKey: + type: string + nullable: true + maxLength: 100 + description: Project key, null when no project was supplied. + submitter: + type: string + maxLength: 255 + description: Optional submitter identity. + submittedAt: + type: string + format: date-time + questions: + type: array + items: + type: object + additionalProperties: true + answers: + type: object + additionalProperties: true + GetFeedbackSubmissionsResponse: + type: object + required: + - submissions + - metadata + properties: + submissions: + type: array + items: + $ref: '#/components/schemas/FeedbackSubmission' + metadata: + $ref: '#/components/schemas/FeedbackSubmissionsMetadata' + FeedbackSubmissionsMetadata: + type: object + required: + - page + - size + - totalElements + - totalPages + - last + properties: + page: + type: integer + size: + type: integer + totalElements: + type: integer + totalPages: + type: integer + last: + type: boolean \ No newline at end of file diff --git a/src/main/java/org/opendevstack/component_catalog/config/ApplicationPropertiesConfiguration.java b/src/main/java/org/opendevstack/component_catalog/config/ApplicationPropertiesConfiguration.java index 8391731..4a1f493 100644 --- a/src/main/java/org/opendevstack/component_catalog/config/ApplicationPropertiesConfiguration.java +++ b/src/main/java/org/opendevstack/component_catalog/config/ApplicationPropertiesConfiguration.java @@ -67,6 +67,12 @@ public OdsApiServerServiceProps odsApiServerServiceProps() { return OdsApiServerServiceProps.builder().build(); } + @Bean("azureAdTokenServiceProps") + @ConfigurationProperties(prefix = "component-catalog.azure.token") + public AzureAdTokenServiceProps azureAdTokenServiceProps() { + return AzureAdTokenServiceProps.builder().build(); + } + @Bean("catalogProjectComponentsGroupsRestrictionConfig") @ConfigurationProperties(prefix = "catalog.project-components.groups-restriction") public CatalogProjectComponentsGroupsRestrictionProps catalogProjectComponentsGroupsRestrictionConfig() { @@ -136,6 +142,13 @@ public static class BitbucketServiceCacheProps { @Data public static class OdsApiServerServiceProps { private String oid; + private URL baseRestUrl; + } + + @Builder + @Data + public static class AzureAdTokenServiceProps { + private String url; } @Builder diff --git a/src/main/java/org/opendevstack/component_catalog/config/ControllerExceptionHandler.java b/src/main/java/org/opendevstack/component_catalog/config/ControllerExceptionHandler.java index f616563..9d3f53a 100644 --- a/src/main/java/org/opendevstack/component_catalog/config/ControllerExceptionHandler.java +++ b/src/main/java/org/opendevstack/component_catalog/config/ControllerExceptionHandler.java @@ -8,6 +8,7 @@ import org.opendevstack.component_catalog.server.services.exceptions.UnableToDeserializeEntityException; import com.fasterxml.jackson.core.JsonProcessingException; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -16,6 +17,9 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.client.RestClientResponseException; + +import java.util.stream.Collectors; @ControllerAdvice @Slf4j @@ -30,7 +34,18 @@ public ResponseEntity handleAllExceptions(Exception ex) { return defaultErrResponse(ex, HttpStatus.INTERNAL_SERVER_ERROR); } - @ExceptionHandler({MissingServletRequestParameterException.class, MethodArgumentTypeMismatchException.class}) + @ExceptionHandler(RestClientResponseException.class) + public ResponseEntity handleRestClientResponseException(RestClientResponseException exception) { + var headers = new HttpHeaders(); + var responseHeaders = exception.getResponseHeaders(); + if (responseHeaders != null) { + headers.putAll(responseHeaders); + } + + return new ResponseEntity<>(exception.getResponseBodyAsByteArray(), headers, exception.getStatusCode()); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity handleRequestParamsExceptions(Exception ex) { log.trace(SENDING_PREDEFINED_HTTP_STATUS, ex); @@ -101,7 +116,26 @@ public ResponseEntity handleInvalidCatalogItemIdStructureExcep } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleMethodArgumentNotValidException(MissingServletRequestParameterException ex) { + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + log.trace(SENDING_PREDEFINED_HTTP_STATUS, ex); + + var message = ex.getFieldErrors().stream() + .filter(error -> error.getDefaultMessage() != null && !error.getDefaultMessage().isBlank()) + .map(error -> "%s: %s".formatted(error.getField(), error.getDefaultMessage())) + .distinct() + .collect(Collectors.joining("; ")); + if (message.isBlank()) { + message = "Request validation failed"; + } + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(new RestErrorMessage(message)); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingServletRequestParameterException(MissingServletRequestParameterException ex) { log.trace(SENDING_PREDEFINED_HTTP_STATUS, ex); return defaultErrResponse(ex, HttpStatus.BAD_REQUEST); diff --git a/src/main/java/org/opendevstack/component_catalog/server/controllers/FeedbackApiController.java b/src/main/java/org/opendevstack/component_catalog/server/controllers/FeedbackApiController.java new file mode 100644 index 0000000..99ef84e --- /dev/null +++ b/src/main/java/org/opendevstack/component_catalog/server/controllers/FeedbackApiController.java @@ -0,0 +1,32 @@ +package org.opendevstack.component_catalog.server.controllers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.opendevstack.component_catalog.server.api.FeedbackSubmissionsApi; +import org.opendevstack.component_catalog.server.model.FeedbackSubmission; +import org.opendevstack.component_catalog.server.model.FeedbackSubmissionRequest; +import org.opendevstack.component_catalog.server.services.FeedbackApiService; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("${openapi.componentCatalogREST.base-path:/v1}") +@RequiredArgsConstructor +@Validated +public class FeedbackApiController implements FeedbackSubmissionsApi { + + private final FeedbackApiService feedbackApiService; + private final ObjectMapper objectMapper; + + @Override + public ResponseEntity registerFeedbackSubmission(FeedbackSubmissionRequest request) { + var clientRequest = objectMapper.convertValue(request, + org.opendevstack.component_catalog.client.feedback_api.v0_0_1.model.FeedbackSubmissionRequest.class); + var response = feedbackApiService.registerFeedbackSubmission(clientRequest); + var submission = objectMapper.convertValue(response.getBody(), FeedbackSubmission.class); + + return ResponseEntity.status(response.getStatusCode()).body(submission); + } +} diff --git a/src/main/java/org/opendevstack/component_catalog/server/services/ApiClientsBuilder.java b/src/main/java/org/opendevstack/component_catalog/server/services/ApiClientsBuilder.java index 8d674fc..ffcf631 100644 --- a/src/main/java/org/opendevstack/component_catalog/server/services/ApiClientsBuilder.java +++ b/src/main/java/org/opendevstack/component_catalog/server/services/ApiClientsBuilder.java @@ -1,15 +1,21 @@ package org.opendevstack.component_catalog.server.services; import lombok.extern.slf4j.Slf4j; +import lombok.RequiredArgsConstructor; import org.opendevstack.component_catalog.client.projects_info_service.v1_0_0.ApiClient; import org.opendevstack.component_catalog.client.projects_info_service.v1_0_0.api.AzureGroupsApi; import org.opendevstack.component_catalog.client.projects_info_service.v1_0_0.api.ProjectsApi; import org.opendevstack.component_catalog.client.projects_info_service.v1_0_0.auth.HttpBearerAuth; +import org.opendevstack.component_catalog.client.feedback_api.v0_0_1.api.FeedbackSubmissionsApi; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; @Service @Slf4j +@RequiredArgsConstructor public class ApiClientsBuilder { + private final RestTemplate restTemplate; + public ApiClient apiClient(String accessToken, String baseRestUrl) { var apiClient = new ApiClient(); @@ -28,4 +34,15 @@ public ProjectsApi projectsApi(ApiClient apiClient) { public AzureGroupsApi azureGroupsApi(ApiClient apiClient) { return new AzureGroupsApi(apiClient); } + + public FeedbackSubmissionsApi feedbackSubmissionsApi(String accessToken, String baseRestUrl) { + var apiClient = new org.opendevstack.component_catalog.client.feedback_api.v0_0_1.ApiClient(restTemplate); + var baseUrl = baseRestUrl.endsWith("/") + ? baseRestUrl.substring(0, baseRestUrl.length() - 1) + : baseRestUrl; + apiClient.setBasePath(baseUrl + "/api/v1/feedback"); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + + return new FeedbackSubmissionsApi(apiClient); + } } \ No newline at end of file diff --git a/src/main/java/org/opendevstack/component_catalog/server/services/ApplicationAuthenticationProvider.java b/src/main/java/org/opendevstack/component_catalog/server/services/ApplicationAuthenticationProvider.java new file mode 100644 index 0000000..d652bff --- /dev/null +++ b/src/main/java/org/opendevstack/component_catalog/server/services/ApplicationAuthenticationProvider.java @@ -0,0 +1,28 @@ +package org.opendevstack.component_catalog.server.services; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class ApplicationAuthenticationProvider { + + private final AzureAdTokenService azureAdTokenService; + private final String clientId; + private final String clientSecret; + private final String scope; + + public ApplicationAuthenticationProvider( + AzureAdTokenService azureAdTokenService, + @Value("${component-catalog.marketplace-api.client-id}") String clientId, + @Value("${component-catalog.marketplace-api.client-secret}") String clientSecret, + @Value("${component-catalog.ods-api-service.params.scope}") String scope) { + this.azureAdTokenService = azureAdTokenService; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.scope = scope; + } + + public String getAccessToken() { + return azureAdTokenService.getAccessToken(clientId, clientSecret, scope); + } +} \ No newline at end of file diff --git a/src/main/java/org/opendevstack/component_catalog/server/services/AzureAdTokenService.java b/src/main/java/org/opendevstack/component_catalog/server/services/AzureAdTokenService.java new file mode 100644 index 0000000..74bb567 --- /dev/null +++ b/src/main/java/org/opendevstack/component_catalog/server/services/AzureAdTokenService.java @@ -0,0 +1,44 @@ +package org.opendevstack.component_catalog.server.services; + +import lombok.AllArgsConstructor; +import org.opendevstack.component_catalog.config.ApplicationPropertiesConfiguration; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Service +@AllArgsConstructor +public class AzureAdTokenService { + + private final RestTemplate restTemplate; + private final ApplicationPropertiesConfiguration.AzureAdTokenServiceProps azureAdTokenServiceProps; + + public String getAccessToken(String clientId, String clientSecret, String scope) { + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "client_credentials"); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("scope", scope); + + HttpEntity> request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity( + azureAdTokenServiceProps.getUrl(), request, AzureTokenResponse.class); + + if (!response.getStatusCode().is2xxSuccessful() + || response.getBody() == null + || response.getBody().getAccessToken() == null + || response.getBody().getAccessToken().isBlank()) { + throw new IllegalStateException("Failed to obtain Azure AD access token"); + } + + return response.getBody().getAccessToken(); + } +} \ No newline at end of file diff --git a/src/main/java/org/opendevstack/component_catalog/server/services/AzureTokenResponse.java b/src/main/java/org/opendevstack/component_catalog/server/services/AzureTokenResponse.java new file mode 100644 index 0000000..2f2d3fd --- /dev/null +++ b/src/main/java/org/opendevstack/component_catalog/server/services/AzureTokenResponse.java @@ -0,0 +1,13 @@ +package org.opendevstack.component_catalog.server.services; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class AzureTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + public String getAccessToken() { + return accessToken; + } +} \ No newline at end of file diff --git a/src/main/java/org/opendevstack/component_catalog/server/services/FeedbackApiService.java b/src/main/java/org/opendevstack/component_catalog/server/services/FeedbackApiService.java new file mode 100644 index 0000000..96059c6 --- /dev/null +++ b/src/main/java/org/opendevstack/component_catalog/server/services/FeedbackApiService.java @@ -0,0 +1,28 @@ +package org.opendevstack.component_catalog.server.services; + +import lombok.AllArgsConstructor; +import org.opendevstack.component_catalog.client.feedback_api.v0_0_1.api.FeedbackSubmissionsApi; +import org.opendevstack.component_catalog.client.feedback_api.v0_0_1.model.FeedbackSubmission; +import org.opendevstack.component_catalog.client.feedback_api.v0_0_1.model.FeedbackSubmissionRequest; +import org.opendevstack.component_catalog.config.ApplicationPropertiesConfiguration; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +public class FeedbackApiService { + + private final ApiClientsBuilder apiClientsBuilder; + private final ApplicationAuthenticationProvider applicationAuthenticationProvider; + + @Qualifier("odsApiServerServiceConfig") + private final ApplicationPropertiesConfiguration.OdsApiServerServiceProps odsApiServiceProps; + + public ResponseEntity registerFeedbackSubmission(FeedbackSubmissionRequest request) { + FeedbackSubmissionsApi feedbackSubmissionsApi = apiClientsBuilder.feedbackSubmissionsApi( + applicationAuthenticationProvider.getAccessToken(), odsApiServiceProps.getBaseRestUrl().toString()); + + return feedbackSubmissionsApi.registerFeedbackSubmissionWithHttpInfo(request); + } +} \ No newline at end of file diff --git a/src/main/resources/application-local.env.template b/src/main/resources/application-local.env.template index d46aef6..846aa2b 100644 --- a/src/main/resources/application-local.env.template +++ b/src/main/resources/application-local.env.template @@ -45,6 +45,15 @@ PROVISIONER_BITBUCKET_BRANCH_NAME=refs/heads/master # Change these: valid OIDs for specific-client endpoints DEVSTACK_MARKETPLACE_API_PERMITTED_OIDS="any-valid-odis-for-token" + +# Change these: Azure AD config for client credentials flow +AZURE_TOKEN_URL="https://login.microsoftonline.com//oauth2/v2.0/token" +DEVSTACK_MARKETPLACE_API_CLIENT_ID= +DEVSTACK_MARKETPLACE_API_CLIENT_SECRET= + +# Change these: ODS API Service settings +ODS_API_SERVICE_URL="https://ods-api-service.example.com/" +ODS_API_SERVICE_SCOPE= ODS_API_SERVICE_OID="1234567-abcd-1234-abcd-1a2b3c4d5e6f7g" PROJECT_COMPONENT_GROUP_RESTRICTIONS_PREFIX="SOME-PREFIX" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 33aab88..da3fe49 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -34,6 +34,9 @@ user-actions: id: ${USER_ACTIONS_ID} default-message-definition-id: ${USER_ACTIONS_DEFAULT_MESSAGE_DEFINITION_ID} component-catalog: + azure: + token: + url: ${AZURE_TOKEN_URL} security: shared-secret: ${SECURITY_SHARED_SECRET} basic-auth: @@ -69,9 +72,15 @@ component-catalog: projects-info-service: service: base-rest-url: ${PROJECTS_INFO_SERVICE_BASE_REST_URL} + marketplace-api: + client-id: ${DEVSTACK_MARKETPLACE_API_CLIENT_ID} + client-secret: ${DEVSTACK_MARKETPLACE_API_CLIENT_SECRET} ods-api-service: service: oid: ${ODS_API_SERVICE_OID} + base-rest-url: ${ODS_API_SERVICE_URL} + params: + scope: ${ODS_API_SERVICE_SCOPE} catalog: project-components: diff --git a/src/test/java/org/opendevstack/component_catalog/config/ControllerExceptionHandlerTest.java b/src/test/java/org/opendevstack/component_catalog/config/ControllerExceptionHandlerTest.java new file mode 100644 index 0000000..08c84c7 --- /dev/null +++ b/src/test/java/org/opendevstack/component_catalog/config/ControllerExceptionHandlerTest.java @@ -0,0 +1,53 @@ +package org.opendevstack.component_catalog.config; + +import org.junit.jupiter.api.Test; +import org.springframework.validation.FieldError; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MethodArgumentNotValidException; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ControllerExceptionHandlerTest { + + @Test + void givenInvalidMethodArgument_whenHandled_thenReturnBadRequestWithBody() { + // given + var exception = mock(MethodArgumentNotValidException.class); + when(exception.getFieldErrors()).thenReturn(List.of(new FieldError( + "feedbackSubmissionRequest", + "submitter", + "testerexample.org", + false, + null, + null, + "must be a well-formed email address"))); + + // when + var response = new ControllerExceptionHandler().handleMethodArgumentNotValidException(exception); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().getMessage()) + .isEqualTo("submitter: must be a well-formed email address"); + } + + @Test + void givenValidationErrorsWithoutMessages_whenHandled_thenReturnFallbackMessage() { + var exception = mock(MethodArgumentNotValidException.class); + when(exception.getFieldErrors()).thenReturn(List.of( + new FieldError("feedbackSubmissionRequest", "submitter", "invalid", false, + null, null, null), + new FieldError("feedbackSubmissionRequest", "projectKey", "", false, + null, null, " "))); + + var response = new ControllerExceptionHandler().handleMethodArgumentNotValidException(exception); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMessage()).isEqualTo("Request validation failed"); + } +} \ No newline at end of file diff --git a/src/test/java/org/opendevstack/component_catalog/server/controllers/FeedbackApiControllerTest.java b/src/test/java/org/opendevstack/component_catalog/server/controllers/FeedbackApiControllerTest.java new file mode 100644 index 0000000..6d75a96 --- /dev/null +++ b/src/test/java/org/opendevstack/component_catalog/server/controllers/FeedbackApiControllerTest.java @@ -0,0 +1,78 @@ +package org.opendevstack.component_catalog.server.controllers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.component_catalog.client.feedback_api.v0_0_1.model.FeedbackSubmission; +import org.opendevstack.component_catalog.client.feedback_api.v0_0_1.model.FeedbackSubmissionRequest; +import org.opendevstack.component_catalog.config.ControllerExceptionHandler; +import org.opendevstack.component_catalog.server.services.FeedbackApiService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FeedbackApiControllerTest { + + @Mock + private FeedbackApiService feedbackApiService; + + private FeedbackApiController feedbackApiController; + + @BeforeEach + void setUp() { + feedbackApiController = new FeedbackApiController(feedbackApiService, new ObjectMapper()); + } + + @Test + void givenFeedbackRequest_whenRegisterSubmission_thenForwardRequest() { + // given + var request = new org.opendevstack.component_catalog.server.model.FeedbackSubmissionRequest( + "submitter@example.com", List.of(Map.of("id", "rating")), Map.of("rating", 5)); + when(feedbackApiService.registerFeedbackSubmission(any(FeedbackSubmissionRequest.class))) + .thenReturn(ResponseEntity.status(HttpStatus.CREATED).build()); + + // when + var response = feedbackApiController.registerFeedbackSubmission(request); + + // then + var requestCaptor = ArgumentCaptor.forClass(FeedbackSubmissionRequest.class); + verify(feedbackApiService).registerFeedbackSubmission(requestCaptor.capture()); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(requestCaptor.getValue().getSubmitter()).isEqualTo("submitter@example.com"); + assertThat(requestCaptor.getValue().getAnswers()).containsEntry("rating", 5); + } + + @Test + void givenUpstreamError_whenHandled_thenPreserveStatusAndBody() { + // given + var body = "{\"message\":\"upstream error\"}".getBytes(StandardCharsets.UTF_8); + var upstreamHeaders = new HttpHeaders(); + upstreamHeaders.setContentType(MediaType.APPLICATION_JSON); + var exception = HttpClientErrorException.create( + HttpStatus.BAD_REQUEST, "Bad Request", upstreamHeaders, body, StandardCharsets.UTF_8); + + // when + var response = new ControllerExceptionHandler().handleRestClientResponseException(exception); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).containsExactly(body); + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + } +} \ No newline at end of file diff --git a/src/test/java/org/opendevstack/component_catalog/server/services/ApiClientsBuilderTest.java b/src/test/java/org/opendevstack/component_catalog/server/services/ApiClientsBuilderTest.java index 6c262bf..fd14b45 100644 --- a/src/test/java/org/opendevstack/component_catalog/server/services/ApiClientsBuilderTest.java +++ b/src/test/java/org/opendevstack/component_catalog/server/services/ApiClientsBuilderTest.java @@ -5,12 +5,14 @@ import org.opendevstack.component_catalog.client.projects_info_service.v1_0_0.api.AzureGroupsApi; import org.opendevstack.component_catalog.client.projects_info_service.v1_0_0.api.ProjectsApi; import org.opendevstack.component_catalog.client.projects_info_service.v1_0_0.auth.HttpBearerAuth; +import org.opendevstack.component_catalog.client.feedback_api.v0_0_1.api.FeedbackSubmissionsApi; +import org.springframework.web.client.RestTemplate; import static org.assertj.core.api.Assertions.assertThat; class ApiClientsBuilderTest { - private final ApiClientsBuilder builder = new ApiClientsBuilder(); + private final ApiClientsBuilder builder = new ApiClientsBuilder(new RestTemplate()); @Test void givenAccessTokenAndBaseUrl_whenApiClient_thenClientConfiguredCorrectly() { @@ -54,4 +56,22 @@ void givenApiClient_whenAzureGroupsApi_thenReturnAzureGroupsApiInstance() { assertThat(api).isNotNull(); assertThat(api.getApiClient()).isSameAs(client); } + + @Test + void givenFeedbackUrlWithoutTrailingSlash_whenFeedbackSubmissionsApi_thenConfigureBasePath() { + FeedbackSubmissionsApi api = builder.feedbackSubmissionsApi( + "test-token", "https://feedback.example.com"); + + assertThat(api.getApiClient().getBasePath()) + .isEqualTo("https://feedback.example.com/api/v1/feedback"); + } + + @Test + void givenFeedbackUrlWithTrailingSlash_whenFeedbackSubmissionsApi_thenConfigureBasePath() { + FeedbackSubmissionsApi api = builder.feedbackSubmissionsApi( + "test-token", "https://feedback.example.com/"); + + assertThat(api.getApiClient().getBasePath()) + .isEqualTo("https://feedback.example.com/api/v1/feedback"); + } } diff --git a/src/test/java/org/opendevstack/component_catalog/server/services/ApplicationAuthenticationProviderTest.java b/src/test/java/org/opendevstack/component_catalog/server/services/ApplicationAuthenticationProviderTest.java new file mode 100644 index 0000000..539cbf0 --- /dev/null +++ b/src/test/java/org/opendevstack/component_catalog/server/services/ApplicationAuthenticationProviderTest.java @@ -0,0 +1,36 @@ +package org.opendevstack.component_catalog.server.services; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ApplicationAuthenticationProviderTest { + + @Mock + private AzureAdTokenService azureAdTokenService; + + @Test + void givenComponentCatalogCredentialsAndOdsScope_whenGetAccessToken_thenRequestMarketplaceToken() { + // given + var clientId = "component-catalog-client-id"; + var clientSecret = "component-catalog-client-secret"; + var odsScope = "api://ods-api/.default"; + when(azureAdTokenService.getAccessToken(clientId, clientSecret, odsScope)) + .thenReturn("component-catalog-access-token"); + var authenticationProvider = new ApplicationAuthenticationProvider( + azureAdTokenService, clientId, clientSecret, odsScope); + + // when + var accessToken = authenticationProvider.getAccessToken(); + + // then + assertThat(accessToken).isEqualTo("component-catalog-access-token"); + verify(azureAdTokenService).getAccessToken(clientId, clientSecret, odsScope); + } +} \ No newline at end of file diff --git a/src/test/java/org/opendevstack/component_catalog/server/services/AzureAdTokenServiceTest.java b/src/test/java/org/opendevstack/component_catalog/server/services/AzureAdTokenServiceTest.java new file mode 100644 index 0000000..d0a290b --- /dev/null +++ b/src/test/java/org/opendevstack/component_catalog/server/services/AzureAdTokenServiceTest.java @@ -0,0 +1,91 @@ +package org.opendevstack.component_catalog.server.services; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.component_catalog.config.ApplicationPropertiesConfiguration; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AzureAdTokenServiceTest { + + private static final String TOKEN_URL = "https://login.example.com/token"; + + @Mock + private RestTemplate restTemplate; + + @Test + void givenSuccessfulTokenResponse_whenGetAccessToken_thenReturnAccessToken() throws Exception { + var tokenResponse = tokenResponse("{\"access_token\":\"access-token\"}"); + mockTokenResponse(ResponseEntity.ok(tokenResponse)); + var service = service(); + + var accessToken = service.getAccessToken("client-id", "client-secret", "api-scope"); + + assertThat(accessToken).isEqualTo("access-token"); + verify(restTemplate).postForEntity(eq(TOKEN_URL), any(HttpEntity.class), eq(AzureTokenResponse.class)); + } + + @Test + void givenNon2xxResponse_whenGetAccessToken_thenThrow() throws Exception { + mockTokenResponse(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(tokenResponse("{\"access_token\":\"access-token\"}"))); + + assertTokenRequestFails(); + } + + @Test + void givenResponseWithoutBody_whenGetAccessToken_thenThrow() { + mockTokenResponse(ResponseEntity.ok().build()); + + assertTokenRequestFails(); + } + + @Test + void givenResponseWithoutAccessToken_whenGetAccessToken_thenThrow() throws Exception { + mockTokenResponse(ResponseEntity.ok(tokenResponse("{\"access_token\":null}"))); + + assertTokenRequestFails(); + } + + @Test + void givenBlankAccessToken_whenGetAccessToken_thenThrow() throws Exception { + mockTokenResponse(ResponseEntity.ok(tokenResponse("{\"access_token\":\" \"}"))); + + assertTokenRequestFails(); + } + + private AzureAdTokenService service() { + var properties = ApplicationPropertiesConfiguration.AzureAdTokenServiceProps.builder() + .url(TOKEN_URL) + .build(); + return new AzureAdTokenService(restTemplate, properties); + } + + private AzureTokenResponse tokenResponse(String json) throws Exception { + return new ObjectMapper().readValue(json, AzureTokenResponse.class); + } + + private void mockTokenResponse(ResponseEntity response) { + when(restTemplate.postForEntity(eq(TOKEN_URL), any(HttpEntity.class), eq(AzureTokenResponse.class))) + .thenReturn(response); + } + + private void assertTokenRequestFails() { + assertThatThrownBy(() -> service().getAccessToken("client-id", "client-secret", "api-scope")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Failed to obtain Azure AD access token"); + } +} \ No newline at end of file diff --git a/src/test/java/org/opendevstack/component_catalog/server/services/FeedbackApiClientTest.java b/src/test/java/org/opendevstack/component_catalog/server/services/FeedbackApiClientTest.java new file mode 100644 index 0000000..829ac7d --- /dev/null +++ b/src/test/java/org/opendevstack/component_catalog/server/services/FeedbackApiClientTest.java @@ -0,0 +1,50 @@ +package org.opendevstack.component_catalog.server.services; + +import org.junit.jupiter.api.Test; +import org.opendevstack.component_catalog.client.feedback_api.v0_0_1.api.FeedbackSubmissionsApi; +import org.opendevstack.component_catalog.client.feedback_api.v0_0_1.model.FeedbackSubmissionRequest; +import org.openapitools.jackson.nullable.JsonNullableModule; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpMethod.POST; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class FeedbackApiClientTest { + + @Test + void givenJsonNullableResponse_whenRegisterSubmission_thenDeserializeProjectKey() { + // given + var objectMapper = new ObjectMapper().registerModule(new JsonNullableModule()); + var restTemplate = new RestTemplate(); + restTemplate.getMessageConverters().add(0, new MappingJackson2HttpMessageConverter(objectMapper)); + var mockServer = MockRestServiceServer.bindTo(restTemplate).build(); + mockServer.expect(requestTo("http://feedback.example.com/api/v1/feedback/submissions")) + .andExpect(method(POST)) + .andRespond(withSuccess("{\"projectKey\":\"PROJECT_KEY\"}", MediaType.APPLICATION_JSON)); + var apiClient = new org.opendevstack.component_catalog.client.feedback_api.v0_0_1.ApiClient(restTemplate); + apiClient.setBasePath("http://feedback.example.com/api/v1/feedback"); + var api = new FeedbackSubmissionsApi(apiClient); + var request = new FeedbackSubmissionRequest() + .submitter("submitter@example.com") + .questions(List.of(Map.of("id", "rating"))) + .answers(Map.of("rating", 5)); + + // when + var response = api.registerFeedbackSubmissionWithHttpInfo(request); + + // then + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getProjectKey()).isEqualTo("PROJECT_KEY"); + mockServer.verify(); + } +} \ No newline at end of file diff --git a/src/test/java/org/opendevstack/component_catalog/server/services/FeedbackApiServiceTest.java b/src/test/java/org/opendevstack/component_catalog/server/services/FeedbackApiServiceTest.java new file mode 100644 index 0000000..14c42f4 --- /dev/null +++ b/src/test/java/org/opendevstack/component_catalog/server/services/FeedbackApiServiceTest.java @@ -0,0 +1,53 @@ +package org.opendevstack.component_catalog.server.services; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.component_catalog.client.feedback_api.v0_0_1.api.FeedbackSubmissionsApi; +import org.opendevstack.component_catalog.client.feedback_api.v0_0_1.model.FeedbackSubmission; +import org.opendevstack.component_catalog.client.feedback_api.v0_0_1.model.FeedbackSubmissionRequest; +import org.opendevstack.component_catalog.config.ApplicationPropertiesConfiguration; +import org.springframework.http.ResponseEntity; + +import java.net.URL; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FeedbackApiServiceTest { + + @Mock + private ApiClientsBuilder apiClientsBuilder; + + @Mock + private ApplicationAuthenticationProvider applicationAuthenticationProvider; + + @Test + void givenFeedbackRequest_whenRegisterSubmission_thenUseAuthenticatedApiAndReturnResponse() throws Exception { + var accessToken = "access-token"; + var baseUrl = new URL("https://ods.example.com"); + var properties = ApplicationPropertiesConfiguration.OdsApiServerServiceProps.builder() + .baseRestUrl(baseUrl) + .build(); + var feedbackApi = mock(FeedbackSubmissionsApi.class); + var request = new FeedbackSubmissionRequest(); + var submission = mock(FeedbackSubmission.class); + var expectedResponse = ResponseEntity.ok(submission); + + when(applicationAuthenticationProvider.getAccessToken()).thenReturn(accessToken); + when(apiClientsBuilder.feedbackSubmissionsApi(accessToken, baseUrl.toString())).thenReturn(feedbackApi); + when(feedbackApi.registerFeedbackSubmissionWithHttpInfo(request)).thenReturn(expectedResponse); + var service = new FeedbackApiService(apiClientsBuilder, applicationAuthenticationProvider, properties); + + var response = service.registerFeedbackSubmission(request); + + assertThat(response).isSameAs(expectedResponse); + verify(applicationAuthenticationProvider).getAccessToken(); + verify(apiClientsBuilder).feedbackSubmissionsApi(accessToken, baseUrl.toString()); + verify(feedbackApi).registerFeedbackSubmissionWithHttpInfo(request); + } +} \ No newline at end of file diff --git a/src/test/resources/application-testing.env b/src/test/resources/application-testing.env index 6fcbe50..d3ece0d 100644 --- a/src/test/resources/application-testing.env +++ b/src/test/resources/application-testing.env @@ -23,6 +23,8 @@ SECURITY_SHARED_SECRET=test-shared-secret USER_ACTIONS_DEFAULT_MESSAGE_DEFINITION_ID="DEFAULT_ERROR" PROJECTS_INFO_SERVICE_BASE_REST_URL="https://projects-info-service-devstack-qa.openshift/v1" +ODS_API_SERVICE_URL="http://localhost:0/" +ODS_API_SERVICE_SCOPE="api://test-ods-api-service/.default" PROVISIONER_BITBUCKET_PROJECT_KEY=CATALOGS PROVISIONER_BITBUCKET_REPOSITORY_SLUG=project-components @@ -31,4 +33,8 @@ PROVISIONER_BITBUCKET_SUB_PATH=projects/.json PROVISIONER_BITBUCKET_SUB_PATH_TOKEN= PROVISIONER_BITBUCKET_BRANCH_NAME=refs/heads/master -DEVSTACK_MARKETPLACE_API_PERMITTED_OIDS="any-valid-odis-for-token" \ No newline at end of file +DEVSTACK_MARKETPLACE_API_PERMITTED_OIDS="any-valid-odis-for-token" + +AZURE_TOKEN_URL="https://login.microsoftonline.com/test-tenant/oauth2/v2.0/token" +DEVSTACK_MARKETPLACE_API_CLIENT_ID="test-client-id" +DEVSTACK_MARKETPLACE_API_CLIENT_SECRET="test-client-secret"