From ddaec0fa69754f2814e1dc97218c9761874870e4 Mon Sep 17 00:00:00 2001 From: JordenReuter <149687553+JordenReuter@users.noreply.github.com> Date: Mon, 4 Mar 2024 08:53:26 +0100 Subject: [PATCH] Feature/p002271 6723 setup and initial impl (#2) * feat: setup and initial impl + tests * feat: added gitignore * fix: removed files to ingore --- .github/changelog.yaml | 13 + .github/dependabot.yml | 14 + .github/workflows/build-branch.yml | 13 + .github/workflows/build-pr.yml | 9 + .github/workflows/build-release.yml | 9 + .github/workflows/build.yml | 15 + .github/workflows/create-fix-branch.yml | 7 + .github/workflows/create-new-build.yml | 9 + .github/workflows/create-release.yml | 7 + .github/workflows/documentation.yml | 10 + .github/workflows/sonar-pr.yml | 12 + .gitignore | 43 +++ pom.xml | 198 +++++++++++ src/main/docker/Dockerfile.jvm | 7 + src/main/docker/Dockerfile.native | 3 + src/main/helm/Chart.yaml | 17 + src/main/helm/values.yaml | 14 + .../rs/controllers/ImagesRestController.java | 114 ++++++ .../bff/rs/mappers/ExceptionMapper.java | 60 ++++ .../welcome/bff/rs/mappers/ImageMapper.java | 22 ++ src/main/openapi/onecx-welcome-bff.yaml | 311 +++++++++++++++++ src/main/resources/application.properties | 63 ++++ .../onecx/welcome/bff/rs/AbstractTest.java | 37 ++ .../welcome/bff/rs/ImageRestControllerIT.java | 7 + .../bff/rs/ImageRestControllerTest.java | 328 ++++++++++++++++++ src/test/resources/images/Testimage.jpg | Bin 0 -> 31292 bytes src/test/resources/mockserver.properties | 16 + src/test/resources/mockserver/internal.json | 0 .../resources/mockserver/permissions.json | 46 +++ 29 files changed, 1404 insertions(+) create mode 100644 .github/changelog.yaml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build-branch.yml create mode 100644 .github/workflows/build-pr.yml create mode 100644 .github/workflows/build-release.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/create-fix-branch.yml create mode 100644 .github/workflows/create-new-build.yml create mode 100644 .github/workflows/create-release.yml create mode 100644 .github/workflows/documentation.yml create mode 100644 .github/workflows/sonar-pr.yml create mode 100644 .gitignore create mode 100644 pom.xml create mode 100644 src/main/docker/Dockerfile.jvm create mode 100644 src/main/docker/Dockerfile.native create mode 100644 src/main/helm/Chart.yaml create mode 100644 src/main/helm/values.yaml create mode 100644 src/main/java/org/tkit/onecx/welcome/bff/rs/controllers/ImagesRestController.java create mode 100644 src/main/java/org/tkit/onecx/welcome/bff/rs/mappers/ExceptionMapper.java create mode 100644 src/main/java/org/tkit/onecx/welcome/bff/rs/mappers/ImageMapper.java create mode 100644 src/main/openapi/onecx-welcome-bff.yaml create mode 100644 src/main/resources/application.properties create mode 100644 src/test/java/org/tkit/onecx/welcome/bff/rs/AbstractTest.java create mode 100644 src/test/java/org/tkit/onecx/welcome/bff/rs/ImageRestControllerIT.java create mode 100644 src/test/java/org/tkit/onecx/welcome/bff/rs/ImageRestControllerTest.java create mode 100644 src/test/resources/images/Testimage.jpg create mode 100644 src/test/resources/mockserver.properties create mode 100644 src/test/resources/mockserver/internal.json create mode 100644 src/test/resources/mockserver/permissions.json diff --git a/.github/changelog.yaml b/.github/changelog.yaml new file mode 100644 index 0000000..0a5526e --- /dev/null +++ b/.github/changelog.yaml @@ -0,0 +1,13 @@ +sections: + - title: Major changes + labels: + - "release/super-feature" + - title: Complete changelog + labels: + - "bug" + - "enhancement" + - "dependencies" +template: | + {{ range $section := .Sections }}{{ if $section.Items }}### {{ $section.GetTitle }}{{ range $item := $section.Items }} + * [#{{ $item.GetID }}]({{ $item.GetURL }}) - {{ $item.GetTitle }}{{ end }}{{ end }} + {{ end }} \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..543ce22 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: maven + directory: "/" + schedule: + interval: daily + labels: + - dependencies + - package-ecosystem: "docker" + directory: "/src/main/docker" + schedule: + interval: daily + labels: + - docker-image \ No newline at end of file diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml new file mode 100644 index 0000000..7270213 --- /dev/null +++ b/.github/workflows/build-branch.yml @@ -0,0 +1,13 @@ +name: Build Feature Branch + +on: + push: + branches: + - '**' + - '!main' + - '!fix/[0-9]+.[0-9]+.x' + +jobs: + branch: + uses: onecx/ci-quarkus/.github/workflows/build-branch.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml new file mode 100644 index 0000000..acff155 --- /dev/null +++ b/.github/workflows/build-pr.yml @@ -0,0 +1,9 @@ +name: Build Pull Request + +on: + pull_request: + +jobs: + pr: + uses: onecx/ci-quarkus/.github/workflows/build-pr.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..b04a59a --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,9 @@ +name: Build Release +on: + push: + tags: + - '**' +jobs: + release: + uses: onecx/ci-quarkus/.github/workflows/build-release.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..8bc82fa --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,15 @@ +name: Build + +on: + workflow_dispatch: + push: + branches: + - 'main' + - 'fix/[0-9]+.[0-9]+.x' + +jobs: + build: + uses: onecx/ci-quarkus/.github/workflows/build.yml@v1 + secrets: inherit + with: + helmEventTargetRepository: onecx/onecx-welcome \ No newline at end of file diff --git a/.github/workflows/create-fix-branch.yml b/.github/workflows/create-fix-branch.yml new file mode 100644 index 0000000..92af624 --- /dev/null +++ b/.github/workflows/create-fix-branch.yml @@ -0,0 +1,7 @@ +name: Create Fix Branch +on: + workflow_dispatch: +jobs: + fix: + uses: onecx/ci-common/.github/workflows/create-fix-branch.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/create-new-build.yml b/.github/workflows/create-new-build.yml new file mode 100644 index 0000000..5051e3c --- /dev/null +++ b/.github/workflows/create-new-build.yml @@ -0,0 +1,9 @@ +name: Create new build + +on: + workflow_dispatch: + +jobs: + build: + uses: onecx/ci-common/.github/workflows/create-new-build.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000..c97eb42 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,7 @@ +name: Create Release Version +on: + workflow_dispatch: +jobs: + release: + uses: onecx/ci-common/.github/workflows/create-release.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..e43d7dc --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,10 @@ +name: Update documentation +on: + push: + branches: [ main ] + paths: + - 'docs/**' +jobs: + release: + uses: onecx/ci-common/.github/workflows/documentation.yml@v1 + secrets: inherit diff --git a/.github/workflows/sonar-pr.yml b/.github/workflows/sonar-pr.yml new file mode 100644 index 0000000..02c3e1e --- /dev/null +++ b/.github/workflows/sonar-pr.yml @@ -0,0 +1,12 @@ +name: Sonar Pull Request + +on: + workflow_run: + workflows: ["Build Pull Request"] + types: + - completed + +jobs: + pr: + uses: onecx/ci-quarkus/.github/workflows/quarkus-pr-sonar.yml@v1 + secrets: inherit \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da86281 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +#Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +release.properties +.flattened-pom.xml + +# Eclipse +.project +.classpath +.settings/ +bin/ + +# IntelliJ +.idea +*.ipr +*.iml +*.iws + +# NetBeans +nb-configuration.xml + +# Visual Studio Code +.vscode +.factorypath + +# OSX +.DS_Store + +# Vim +*.swp +*.swo + +# patch +*.orig +*.rej + +# Local environment +.env + +# Plugin directory +/.quarkus/cli/plugins/ \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8700b06 --- /dev/null +++ b/pom.xml @@ -0,0 +1,198 @@ + + + 4.0.0 + + org.tkit.onecx + onecx-quarkus3-parent + 0.36.0 + + + onecx-welcome-bff + 999-SNAPSHOT + + + + io.quarkus + quarkus-resteasy-reactive + + + io.quarkus + quarkus-smallrye-openapi + + + io.quarkus + quarkus-resteasy-reactive-jackson + + + io.quarkus + quarkus-smallrye-health + + + io.quarkus + quarkus-opentelemetry + + + io.quarkus + quarkus-micrometer-registry-prometheus + + + io.quarkiverse.openapi.generator + quarkus-openapi-generator + + + io.quarkus + quarkus-rest-client-reactive-jackson + + + + org.tkit.quarkus.lib + tkit-quarkus-log-cdi + + + org.tkit.quarkus.lib + tkit-quarkus-log-rs + + + org.tkit.quarkus.lib + tkit-quarkus-log-json + + + org.tkit.quarkus.lib + tkit-quarkus-rest + + + org.tkit.quarkus.lib + tkit-quarkus-rest-context + + + org.tkit.quarkus.lib + tkit-quarkus-jpa + + + org.mapstruct + mapstruct + + + io.quarkus + quarkus-hibernate-validator + + + org.tkit.onecx.quarkus + onecx-permissions + + + io.quarkus + quarkus-oidc + + + io.quarkus + quarkus-oidc-client-reactive-filter + + + org.tkit.quarkus.lib + tkit-quarkus-security + + + + io.quarkiverse.mockserver + quarkus-mockserver + provided + + + + + io.quarkiverse.mockserver + quarkus-mockserver-test + + + io.swagger.parser.v3 + swagger-parser + + + test + + + io.swagger.parser.v3 + swagger-parser + test + + + io.quarkus + quarkus-test-keycloak-server + test + + + + + + + org.openapitools + openapi-generator-maven-plugin + + + internal + + generate + + + src/main/openapi/onecx-welcome-bff.yaml + gen.org.tkit.onecx.welcome.bff.rs.internal + gen.org.tkit.onecx.welcome.bff.rs.internal.model + File=byte[] + + + + + onecx-permissions=true + jaxrs-spec + ApiService + DTO + false + false + false + false + false + true + quarkus + + / + false + true + true + true + true + true + java8 + true + true + false + true + + + + + com.googlecode.maven-download-plugin + download-maven-plugin + + + welcome-svc-internal + generate-resources + + wget + + + + https://raw.githubusercontent.com/onecx/onecx-welcome-svc/main/src/main/openapi/onecx-welcome-internal-openapi.yaml + + target/tmp/openapi + onecx-welcome-internal.yaml + true + + + + + + + \ No newline at end of file diff --git a/src/main/docker/Dockerfile.jvm b/src/main/docker/Dockerfile.jvm new file mode 100644 index 0000000..327bf74 --- /dev/null +++ b/src/main/docker/Dockerfile.jvm @@ -0,0 +1,7 @@ +FROM ghcr.io/onecx/docker-quarkus-jvm:0.4.0 + +COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 target/quarkus-app/*.jar /deployments/ +COPY --chown=185 target/quarkus-app/app/ /deployments/app/ +COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/ +USER 185 \ No newline at end of file diff --git a/src/main/docker/Dockerfile.native b/src/main/docker/Dockerfile.native new file mode 100644 index 0000000..c0ba5ed --- /dev/null +++ b/src/main/docker/Dockerfile.native @@ -0,0 +1,3 @@ +FROM ghcr.io/onecx/docker-quarkus-native:0.2.0 + +COPY --chown=1001:root target/*-runner /work/application diff --git a/src/main/helm/Chart.yaml b/src/main/helm/Chart.yaml new file mode 100644 index 0000000..f5288b1 --- /dev/null +++ b/src/main/helm/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v2 +name: onecx-welcome-bff +version: 0.0.0 +appVersion: 0.0.0 +description: Onecx Welcome BFF +keywords: + - welcome +sources: + - https://github.com/onecx/onecx-welcome-bff +maintainers: + - name: Tkit Developer + email: tkit_dev@1000kit.org +dependencies: + - name: helm-quarkus-app + alias: app + version: ^0 + repository: oci://ghcr.io/onecx/charts \ No newline at end of file diff --git a/src/main/helm/values.yaml b/src/main/helm/values.yaml new file mode 100644 index 0000000..2e89fae --- /dev/null +++ b/src/main/helm/values.yaml @@ -0,0 +1,14 @@ +app: + name: bff + image: + repository: "onecx/onecx-welcome-bff" + operator: + # Permission + permission: + enabled: true + spec: + permissions: + welcome: + read: permission on all GET requests and POST search + write: permission on PUT, POST, PATCH requests, where objects are saved or updated + delete: permission on all DELETE requests \ No newline at end of file diff --git a/src/main/java/org/tkit/onecx/welcome/bff/rs/controllers/ImagesRestController.java b/src/main/java/org/tkit/onecx/welcome/bff/rs/controllers/ImagesRestController.java new file mode 100644 index 0000000..cc524a2 --- /dev/null +++ b/src/main/java/org/tkit/onecx/welcome/bff/rs/controllers/ImagesRestController.java @@ -0,0 +1,114 @@ +package org.tkit.onecx.welcome.bff.rs.controllers; + +import java.util.Arrays; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.validation.ConstraintViolationException; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.resteasy.reactive.RestResponse; +import org.jboss.resteasy.reactive.server.ServerExceptionMapper; +import org.tkit.onecx.welcome.bff.rs.mappers.ExceptionMapper; +import org.tkit.onecx.welcome.bff.rs.mappers.ImageMapper; +import org.tkit.quarkus.log.cdi.LogService; + +import gen.org.tkit.onecx.welcome.bff.rs.internal.ImagesInternalApiService; +import gen.org.tkit.onecx.welcome.bff.rs.internal.model.ImageDataResponseDTO; +import gen.org.tkit.onecx.welcome.bff.rs.internal.model.ImageInfoDTO; +import gen.org.tkit.onecx.welcome.bff.rs.internal.model.ProblemDetailResponseDTO; +import gen.org.tkit.onecx.welcome.client.api.ImagesInternalApi; +import gen.org.tkit.onecx.welcome.client.model.ImageDataResponse; +import gen.org.tkit.onecx.welcome.client.model.ImageInfo; + +@ApplicationScoped +@Transactional(value = Transactional.TxType.NOT_SUPPORTED) +@LogService +public class ImagesRestController implements ImagesInternalApiService { + @Inject + @RestClient + ImagesInternalApi welcomeClient; + + @Inject + ImageMapper mapper; + + @Inject + ExceptionMapper exceptionMapper; + + @Override + public Response createImage(Integer contentLength, byte[] body) { + try (Response response = welcomeClient.createImage(contentLength, body)) { + ImageDataResponseDTO responseDTO = mapper.map(response.readEntity(ImageDataResponse.class)); + return Response.status(response.getStatus()).entity(responseDTO).build(); + } + } + + @Override + public Response createImageInfo(ImageInfoDTO imageInfoDTO) { + try (Response response = welcomeClient.createImageInfo(mapper.map(imageInfoDTO))) { + ImageInfoDTO responseDTO = mapper.map(response.readEntity(ImageInfo.class)); + return Response.status(response.getStatus()).entity(responseDTO).build(); + } + } + + @Override + public Response deleteImageInfoById(String id) { + try (Response response = welcomeClient.deleteImageInfoById(id)) { + return Response.status(response.getStatus()).build(); + } + } + + @Override + public Response getAllImageInfos() { + try (Response response = welcomeClient.getAllImageInfos()) { + List infoList = mapper.map(Arrays.stream(response.readEntity(ImageInfo[].class)).toList()); + return Response.status(response.getStatus()).entity(infoList).build(); + } + } + + @Override + public Response getImageById(String id) { + try (Response response = welcomeClient.getImageById(id)) { + ImageDataResponseDTO responseDTO = mapper.map(response.readEntity(ImageDataResponse.class)); + return Response.status(response.getStatus()).entity(responseDTO).build(); + } + } + + @Override + public Response getImageInfoById(String id) { + try (Response response = welcomeClient.getImageInfoById(id)) { + ImageInfoDTO infoDTO = mapper.map(response.readEntity(ImageInfo.class)); + return Response.status(response.getStatus()).entity(infoDTO).build(); + } + } + + @Override + public Response updateImageById(String id, Integer contentLength, byte[] body) { + try (Response response = welcomeClient.updateImageById(id, contentLength, body)) { + ImageDataResponseDTO responseDTO = mapper.map(response.readEntity(ImageDataResponse.class)); + return Response.status(response.getStatus()).entity(responseDTO).build(); + } + } + + @Override + public Response updateImageInfo(String id, ImageInfoDTO imageInfoDTO) { + try (Response response = welcomeClient.updateImageInfo(id, mapper.map(imageInfoDTO))) { + ImageInfoDTO responseDTO = mapper.map(response.readEntity(ImageInfo.class)); + return Response.status(response.getStatus()).entity(responseDTO).build(); + } + } + + @ServerExceptionMapper + public RestResponse constraint(ConstraintViolationException ex) { + return exceptionMapper.constraint(ex); + } + + @ServerExceptionMapper + public Response restException(WebApplicationException ex) { + return Response.status(ex.getResponse().getStatus()).build(); + } +} diff --git a/src/main/java/org/tkit/onecx/welcome/bff/rs/mappers/ExceptionMapper.java b/src/main/java/org/tkit/onecx/welcome/bff/rs/mappers/ExceptionMapper.java new file mode 100644 index 0000000..9cd6ffb --- /dev/null +++ b/src/main/java/org/tkit/onecx/welcome/bff/rs/mappers/ExceptionMapper.java @@ -0,0 +1,60 @@ +package org.tkit.onecx.welcome.bff.rs.mappers; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Path; +import jakarta.ws.rs.core.Response; + +import org.jboss.resteasy.reactive.RestResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.tkit.quarkus.rs.mappers.OffsetDateTimeMapper; + +import gen.org.tkit.onecx.welcome.bff.rs.internal.model.ProblemDetailInvalidParamDTO; +import gen.org.tkit.onecx.welcome.bff.rs.internal.model.ProblemDetailParamDTO; +import gen.org.tkit.onecx.welcome.bff.rs.internal.model.ProblemDetailResponseDTO; + +@Mapper(uses = { OffsetDateTimeMapper.class }) +public interface ExceptionMapper { + + default RestResponse constraint(ConstraintViolationException ex) { + var dto = exception("CONSTRAINT_VIOLATIONS", ex.getMessage()); + dto.setInvalidParams(createErrorValidationResponse(ex.getConstraintViolations())); + return RestResponse.status(Response.Status.BAD_REQUEST, dto); + } + + @Mapping(target = "removeParamsItem", ignore = true) + @Mapping(target = "params", ignore = true) + @Mapping(target = "invalidParams", ignore = true) + @Mapping(target = "removeInvalidParamsItem", ignore = true) + ProblemDetailResponseDTO exception(String errorCode, String detail); + + default List map(Map params) { + if (params == null) { + return List.of(); + } + return params.entrySet().stream().map(e -> { + var item = new ProblemDetailParamDTO(); + item.setKey(e.getKey()); + if (e.getValue() != null) { + item.setValue(e.getValue().toString()); + } + return item; + }).toList(); + } + + List createErrorValidationResponse( + Set> constraintViolation); + + @Mapping(target = "name", source = "propertyPath") + @Mapping(target = "message", source = "message") + ProblemDetailInvalidParamDTO createError(ConstraintViolation constraintViolation); + + default String mapPath(Path path) { + return path.toString(); + } +} diff --git a/src/main/java/org/tkit/onecx/welcome/bff/rs/mappers/ImageMapper.java b/src/main/java/org/tkit/onecx/welcome/bff/rs/mappers/ImageMapper.java new file mode 100644 index 0000000..256d4ee --- /dev/null +++ b/src/main/java/org/tkit/onecx/welcome/bff/rs/mappers/ImageMapper.java @@ -0,0 +1,22 @@ +package org.tkit.onecx.welcome.bff.rs.mappers; + +import java.util.List; + +import org.mapstruct.Mapper; +import org.tkit.quarkus.rs.mappers.OffsetDateTimeMapper; + +import gen.org.tkit.onecx.welcome.bff.rs.internal.model.ImageDataResponseDTO; +import gen.org.tkit.onecx.welcome.bff.rs.internal.model.ImageInfoDTO; +import gen.org.tkit.onecx.welcome.client.model.ImageDataResponse; +import gen.org.tkit.onecx.welcome.client.model.ImageInfo; + +@Mapper(uses = { OffsetDateTimeMapper.class }) +public interface ImageMapper { + ImageDataResponseDTO map(ImageDataResponse imageDataResponse); + + ImageInfo map(ImageInfoDTO info); + + ImageInfoDTO map(ImageInfo imageInfo); + + List map(List list); +} diff --git a/src/main/openapi/onecx-welcome-bff.yaml b/src/main/openapi/onecx-welcome-bff.yaml new file mode 100644 index 0000000..a2975a3 --- /dev/null +++ b/src/main/openapi/onecx-welcome-bff.yaml @@ -0,0 +1,311 @@ +--- +openapi: 3.0.3 +info: + title: onecx-welcome-bff + description: welcome bff + version: 1.0.0 + contact: + email: "tkit_dev@1000kit.org" + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html +servers: + - url: "http://onecx-welcome-bff:8080" +tags: + - name: OneCX Welcome Bff +paths: + /images/info/{id}: + get: + x-onecx: + permissions: + welcome: + - read + tags: + - imagesInternal + description: get Image information by id + operationId: getImageInfoById + parameters: + - $ref: '#/components/parameters/id' + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ImageInfo" + "404": + description: NOT FOUND + put: + x-onecx: + permissions: + welcome: + - write + tags: + - imagesInternal + description: update Image information + operationId: updateImageInfo + parameters: + - $ref: '#/components/parameters/id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ImageInfo' + responses: + "200": + description: UPDATED + content: + application/json: + schema: + $ref: '#/components/schemas/ImageInfo' + "400": + description: BAD REQUEST + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetailResponse' + delete: + x-onecx: + permissions: + welcome: + - delete + tags: + - imagesInternal + description: delete image information + operationId: deleteImageInfoById + parameters: + - $ref: '#/components/parameters/id' + responses: + "204": + description: DELETED + "404": + description: NOT FOUND + /images/info: + post: + x-onecx: + permissions: + welcome: + - write + tags: + - imagesInternal + description: create image info + operationId: createImageInfo + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ImageInfo' + responses: + "201": + description: CREATED + "400": + description: BAD REQUEST + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetailResponse' + get: + x-onecx: + permissions: + welcome: + - read + tags: + - imagesInternal + description: get all existing image-infos + operationId: getAllImageInfos + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ImageInfo' + /images: + post: + x-onecx: + permissions: + welcome: + - write + tags: + - imagesInternal + description: create image + operationId: createImage + parameters: + - in: header + name: Content-Length + required: true + schema: + minimum: 1 + maximum: 110000 + type: integer + requestBody: + content: + image/*: + schema: + type: string + format: binary + responses: + "201": + description: CREATED + content: + application/json: + schema: + $ref: '#/components/schemas/ImageDataResponse' + "400": + description: BAD REQUEST + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetailResponse' + /images/{id}: + get: + x-onecx: + permissions: + welcome: + - read + tags: + - imagesInternal + description: get Image by id + operationId: getImageById + parameters: + - $ref: '#/components/parameters/id' + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ImageDataResponse' + "404": + description: NOT FOUND + put: + x-onecx: + permissions: + welcome: + - write + tags: + - imagesInternal + description: update Image by id + operationId: updateImageById + parameters: + - $ref: '#/components/parameters/id' + - in: header + name: Content-Length + required: true + schema: + minimum: 1 + maximum: 110000 + type: integer + requestBody: + content: + image/*: + schema: + type: string + format: binary + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ImageDataResponse' + "400": + description: BAD REQUEST + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetailResponse' +components: + schemas: + ImageDataResponse: + type: object + properties: + imageId: + type: string + imageData: + type: string + format: binary + dataLength: + type: integer + mimeType: + type: string + modificationCount: + format: int32 + type: integer + creationDate: + $ref: '#/components/schemas/OffsetDateTime' + creationUser: + type: string + modificationDate: + $ref: '#/components/schemas/OffsetDateTime' + modificationUser: + type: string + ImageInfo: + type: object + properties: + position: + type: string + visible: + type: boolean + url: + type: string + imageId: + type: string + id: + type: string + modificationCount: + format: int32 + type: integer + creationDate: + $ref: '#/components/schemas/OffsetDateTime' + creationUser: + type: string + modificationDate: + $ref: '#/components/schemas/OffsetDateTime' + modificationUser: + type: string + OffsetDateTime: + format: date-time + type: string + example: 2022-03-10T12:15:50-04:00 + ProblemDetailResponse: + type: object + properties: + errorCode: + type: string + detail: + type: string + params: + type: array + items: + $ref: '#/components/schemas/ProblemDetailParam' + invalidParams: + type: array + items: + $ref: '#/components/schemas/ProblemDetailInvalidParam' + ProblemDetailParam: + type: object + properties: + key: + type: string + value: + type: string + ProblemDetailInvalidParam: + type: object + properties: + name: + type: string + message: + type: string + parameters: + id: + name: id + in: path + required: true + schema: + type: string + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..34f5eff --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,63 @@ +# AUTHENTICATED +quarkus.http.auth.permission.health.paths=/q/* +quarkus.http.auth.permission.health.policy=permit +quarkus.http.auth.permission.default.paths=/* +quarkus.http.auth.permission.default.policy=authenticated + +onecx.permissions.application-id=${quarkus.application.name} + +# propagate the apm-principal-token from requests we receive +org.eclipse.microprofile.rest.client.propagateHeaders=apm-principal-token + +# PROD +%prod.quarkus.rest-client.onecx_welcome_internal.url=http://onecx-welcome-svc:8080 +%prod.quarkus.oidc-client.client-id=${quarkus.application.name} + +# DEV +%dev.quarkus.rest-client.onecx_welcome_internal.url=http://onecx-welcome-svc +%dev.quarkus.oidc-client.auth-server-url=${quarkus.oidc.auth-server-url} +%dev.quarkus.oidc-client.client-id=${quarkus.oidc.client-id} +%dev.quarkus.oidc-client.credentials.secret=${quarkus.oidc.credentials.secret} +%dev.quarkus.rest-client.onecx_user_profile_svc.url=${quarkus.mockserver.endpoint} +%dev.quarkus.rest-client.onecx_permission.url=${quarkus.mockserver.endpoint} +%dev.quarkus.mockserver.devservices.config-file=src/test/resources/mockserver.properties +%dev.quarkus.mockserver.devservices.config-dir=src/test/resources/mockserver + +# BUILD +# welcome-svc client +quarkus.openapi-generator.codegen.spec.onecx_welcome_internal_yaml.config-key=onecx_welcome_internal +quarkus.openapi-generator.codegen.spec.onecx_welcome_internal_yaml.base-package=gen.org.tkit.onecx.welcome.client +quarkus.openapi-generator.codegen.spec.onecx_welcome_internal_yaml.return-response=true +quarkus.openapi-generator.codegen.spec.onecx_welcome_internal_yaml.additional-api-type-annotations=@org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders; +quarkus.openapi-generator.codegen.spec.onecx_welcome_internal_yaml.type-mappings.File=byte[] + +quarkus.openapi-generator.codegen.input-base-dir=target/tmp/openapi + +# INTEGRATION TEST +quarkus.test.integration-test-profile=test + +# TEST +%test.quarkus.http.test-port=0 +%test.tkit.log.json.enabled=false +%test.quarkus.mockserver.devservices.config-class-path=true +%test.quarkus.mockserver.devservices.log=false +%test.quarkus.mockserver.devservices.reuse=true +%test.quarkus.mockserver.devservices.config-file=/mockserver.properties +%test.quarkus.mockserver.devservices.config-dir=/mockserver +%test.quarkus.rest-client.onecx_welcome_internal.url=${quarkus.mockserver.endpoint} + +%test.tkit.rs.context.token.header-param=apm-principal-token +%test.tkit.rs.context.token.enabled=false +%test.quarkus.rest-client.onecx_welcome_internal.providers=io.quarkus.oidc.client.reactive.filter.OidcClientRequestReactiveFilter +%test.tkit.rs.context.tenant-id.mock.claim-org-id=orgId +%test.quarkus.rest-client.onecx_permission.url=${quarkus.mockserver.endpoint} +%test.quarkus.keycloak.devservices.roles.alice=role-admin +%test.quarkus.keycloak.devservices.roles.bob=role-user +%test.quarkus.oidc-client.auth-server-url=${quarkus.oidc.auth-server-url} +%test.quarkus.oidc-client.client-id=${quarkus.oidc.client-id} +%test.quarkus.oidc-client.credentials.secret=${quarkus.oidc.credentials.secret} +%test.onecx.permissions.product-name=applications + +# PIPE CONFIG + + diff --git a/src/test/java/org/tkit/onecx/welcome/bff/rs/AbstractTest.java b/src/test/java/org/tkit/onecx/welcome/bff/rs/AbstractTest.java new file mode 100644 index 0000000..2153c8a --- /dev/null +++ b/src/test/java/org/tkit/onecx/welcome/bff/rs/AbstractTest.java @@ -0,0 +1,37 @@ +package org.tkit.onecx.welcome.bff.rs; + +import org.eclipse.microprofile.config.ConfigProvider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import io.quarkiverse.mockserver.test.MockServerTestResource; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.keycloak.client.KeycloakTestClient; +import io.restassured.RestAssured; +import io.restassured.config.ObjectMapperConfig; +import io.restassured.config.RestAssuredConfig; + +@QuarkusTestResource(MockServerTestResource.class) +public abstract class AbstractTest { + protected static final String ADMIN = "alice"; + + KeycloakTestClient keycloakClient = new KeycloakTestClient(); + + protected static final String USER = "bob"; + + protected static final String APM_HEADER_PARAM = ConfigProvider.getConfig() + .getValue("%test.tkit.rs.context.token.header-param", String.class); + + static { + RestAssured.config = RestAssuredConfig.config().objectMapperConfig( + ObjectMapperConfig.objectMapperConfig().jackson2ObjectMapperFactory( + (cls, charset) -> { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + return objectMapper; + })); + } +} diff --git a/src/test/java/org/tkit/onecx/welcome/bff/rs/ImageRestControllerIT.java b/src/test/java/org/tkit/onecx/welcome/bff/rs/ImageRestControllerIT.java new file mode 100644 index 0000000..222ff94 --- /dev/null +++ b/src/test/java/org/tkit/onecx/welcome/bff/rs/ImageRestControllerIT.java @@ -0,0 +1,7 @@ +package org.tkit.onecx.welcome.bff.rs; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class ImageRestControllerIT extends ImageRestControllerTest { +} diff --git a/src/test/java/org/tkit/onecx/welcome/bff/rs/ImageRestControllerTest.java b/src/test/java/org/tkit/onecx/welcome/bff/rs/ImageRestControllerTest.java new file mode 100644 index 0000000..7e79dfb --- /dev/null +++ b/src/test/java/org/tkit/onecx/welcome/bff/rs/ImageRestControllerTest.java @@ -0,0 +1,328 @@ +package org.tkit.onecx.welcome.bff.rs; + +import static io.restassured.RestAssured.given; +import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON; +import static jakarta.ws.rs.core.Response.Status.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +import java.io.File; +import java.io.IOException; +import java.util.*; + +import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.core.Response; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockserver.client.MockServerClient; +import org.mockserver.model.JsonBody; +import org.mockserver.model.MediaType; +import org.testcontainers.shaded.org.apache.commons.io.FileUtils; +import org.tkit.onecx.welcome.bff.rs.controllers.ImagesRestController; +import org.tkit.quarkus.log.cdi.LogService; + +import gen.org.tkit.onecx.welcome.bff.rs.internal.model.ImageDataResponseDTO; +import gen.org.tkit.onecx.welcome.bff.rs.internal.model.ImageInfoDTO; +import gen.org.tkit.onecx.welcome.bff.rs.internal.model.ProblemDetailResponseDTO; +import gen.org.tkit.onecx.welcome.client.model.ImageDataResponse; +import gen.org.tkit.onecx.welcome.client.model.ImageInfo; +import io.quarkiverse.mockserver.test.InjectMockServerClient; +import io.quarkus.test.common.http.TestHTTPEndpoint; +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +@LogService +@TestHTTPEndpoint(ImagesRestController.class) +public class ImageRestControllerTest extends AbstractTest { + @InjectMockServerClient + MockServerClient mockServerClient; + + private static final String MEDIA_TYPE_IMAGE_PNG = "image/png"; + + private static final File FILE = new File( + Objects.requireNonNull(ImagesRestController.class.getResource("/images/Testimage.jpg")).getFile()); + + static final String mockId = "MOCK"; + + @BeforeEach + void resetExpectation() { + try { + mockServerClient.clear(mockId); + } catch (Exception ex) { + // mockId not existing + } + } + + @Test + void getImageDataByIdTest() { + ImageDataResponse response = new ImageDataResponse(); + response.setImageData(new byte[] { (byte) 0xe0, 0x4f, (byte) 0xd0, + 0x20, (byte) 0xea, 0x3a, 0x69, 0x10, (byte) 0xa2, (byte) 0xd8, 0x08, 0x00, 0x2b, + 0x30, 0x30, (byte) 0x9d }); + response.setImageId("id"); + + // create mock rest endpoint + mockServerClient.when(request().withPath("/internal/images/" + "d-11-111").withMethod(HttpMethod.GET)) + .withId(mockId) + .respond(httpRequest -> response().withStatusCode(Response.Status.OK.getStatusCode()) + .withContentType(MediaType.APPLICATION_JSON) + .withBody(JsonBody.json(response))); + + var data = given() + .contentType(APPLICATION_JSON) + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) + .pathParam("id", "d-11-111") + .get("/{id}") + .then() + .statusCode(OK.getStatusCode()) + .extract().as(ImageDataResponseDTO.class); + + assertThat(data).isNotNull(); + assertThat(data.getImageData()).isNotEmpty(); + } + + @Test + void getImageInfoByIdTest() { + ImageInfo response = new ImageInfo(); + response.visible(true).imageId("someId").position("0"); + + // create mock rest endpoint + mockServerClient.when(request().withPath("/internal/images/info/" + "11-111").withMethod(HttpMethod.GET)) + .withId(mockId) + .respond(httpRequest -> response().withStatusCode(Response.Status.OK.getStatusCode()) + .withContentType(MediaType.APPLICATION_JSON) + .withBody(JsonBody.json(response))); + var data = given() + .contentType(APPLICATION_JSON) + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) + .pathParam("id", "11-111") + .get("/info/{id}") + .then() + .statusCode(OK.getStatusCode()) + .extract().as(ImageInfoDTO.class); + + assertThat(data).isNotNull(); + assertThat(data.getImageId()).isEqualTo("someId"); + assertThat(data.getPosition()).isEqualTo("0"); + + } + + @Test + void createImageDataTest() throws IOException { + + ImageDataResponse response = new ImageDataResponse(); + response.setImageData(Base64.getEncoder().encode(FileUtils.readFileToByteArray(FILE))); + response.setImageId("id"); + + // create mock rest endpoint + mockServerClient.when(request().withPath("/internal/images").withMethod(HttpMethod.POST) + .withContentType(MediaType.ANY_IMAGE_TYPE) + .withBody(FileUtils.readFileToByteArray(FILE))) + .withId(mockId) + .respond(httpRequest -> response().withStatusCode(Response.Status.OK.getStatusCode()) + .withContentType(MediaType.APPLICATION_JSON) + .withBody(JsonBody.json(response))); + + var data = given() + .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) + .body(FILE) + .contentType(MEDIA_TYPE_IMAGE_PNG) + .post() + .then() + .statusCode(OK.getStatusCode()) + .extract() + .body().as(ImageDataResponseDTO.class); + assertThat(data).isNotNull(); + } + + @Test + void createImageInfoTest() { + + ImageInfo info = new ImageInfo(); + info.setUrl("someUrl"); + info.setPosition("1"); + + // create mock rest endpoint + mockServerClient.when(request().withPath("/internal/images/info").withMethod(HttpMethod.POST) + .withContentType(MediaType.APPLICATION_JSON) + .withBody(JsonBody.json(info))) + .withId(mockId) + .respond(httpRequest -> response().withStatusCode(CREATED.getStatusCode()) + .withContentType(MediaType.APPLICATION_JSON) + .withBody(JsonBody.json(info))); + + ImageInfoDTO infoDTO = new ImageInfoDTO(); + infoDTO.setPosition("1"); + infoDTO.setUrl("someUrl"); + + var output = given() + .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) + .body(infoDTO) + .contentType(APPLICATION_JSON) + .post("/info") + .then() + .statusCode(CREATED.getStatusCode()) + .extract().as(ImageInfoDTO.class); + + assertThat(output).isNotNull(); + assertThat(output.getUrl()).isEqualTo(info.getUrl()); + } + + @Test + void updateImageDataByIdTest() throws IOException { + ImageDataResponse response = new ImageDataResponse(); + response.setImageData(Base64.getEncoder().encode(FileUtils.readFileToByteArray(FILE))); + response.setImageId("id"); + + // create mock rest endpoint + mockServerClient.when(request().withPath("/internal/images/d-11-111").withMethod(HttpMethod.PUT) + .withContentType(MediaType.ANY_IMAGE_TYPE) + .withBody(FileUtils.readFileToByteArray(FILE))) + .withId(mockId) + .respond(httpRequest -> response().withStatusCode(Response.Status.OK.getStatusCode()) + .withContentType(MediaType.APPLICATION_JSON) + .withBody(JsonBody.json(response))); + + var data = given() + .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) + .body(FILE) + .contentType(MEDIA_TYPE_IMAGE_PNG) + .pathParam("id", "d-11-111") + .put("{id}") + .then() + .statusCode(OK.getStatusCode()) + .extract() + .body().as(ImageDataResponseDTO.class); + + assertThat(data).isNotNull(); + assertThat(data.getImageData()).isEqualTo(response.getImageData()); + + // create mock rest endpoint + mockServerClient.when(request().withPath("/internal/images/abc").withMethod(HttpMethod.PUT) + .withContentType(MediaType.ANY_IMAGE_TYPE) + .withBody(FileUtils.readFileToByteArray(FILE))) + .withId(mockId) + .respond(httpRequest -> response().withStatusCode(NOT_FOUND.getStatusCode())); + + //update not-existing image + given() + .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) + .body(FILE) + .contentType(MEDIA_TYPE_IMAGE_PNG) + .pathParam("id", "not-existing") + .put("/{id}") + .then() + .statusCode(NOT_FOUND.getStatusCode()); + } + + @Test + void updateImageInfoByIdTest() { + ImageInfo info = new ImageInfo(); + info.setUrl("someUrl"); + info.setPosition("1"); + + // create mock rest endpoint + mockServerClient.when(request().withPath("/internal/images/info/11-111").withMethod(HttpMethod.PUT) + .withContentType(MediaType.APPLICATION_JSON) + .withBody(JsonBody.json(info))) + .withId(mockId) + .respond(httpRequest -> response().withStatusCode(CREATED.getStatusCode()) + .withContentType(MediaType.APPLICATION_JSON) + .withBody(JsonBody.json(info))); + + ImageInfoDTO infoDTO = new ImageInfoDTO(); + infoDTO.setPosition("1"); + infoDTO.setUrl("someUrl"); + + var output = given() + .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) + .body(infoDTO) + .contentType(APPLICATION_JSON) + .pathParam("id", "11-111") + .put("/info/{id}") + .then() + .statusCode(CREATED.getStatusCode()) + .extract().as(ImageInfoDTO.class); + + assertThat(output).isNotNull(); + assertThat(output.getUrl()).isEqualTo(info.getUrl()); + } + + @Test + void deleteImageInfoByIdTest() { + + // create mock rest endpoint + mockServerClient.when(request().withPath("/internal/images/info/" + "11-111").withMethod(HttpMethod.DELETE)) + .withId(mockId) + .respond(httpRequest -> response().withStatusCode(NO_CONTENT.getStatusCode()) + .withContentType(MediaType.APPLICATION_JSON)); + + given() + .pathParam("id", "11-111") + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) + .delete("/info/{id}") + .then() + .statusCode(NO_CONTENT.getStatusCode()); + } + + @Test + void getAllImageInfosTest() { + List infos = new ArrayList<>(); + ImageInfo info = new ImageInfo(); + info.setImageId("111"); + infos.add(info); + + // create mock rest endpoint + mockServerClient.when(request().withPath("/internal/images/info").withMethod(HttpMethod.GET)) + .withId(mockId) + .respond(httpRequest -> response().withStatusCode(Response.Status.OK.getStatusCode()) + .withContentType(MediaType.APPLICATION_JSON) + .withBody(JsonBody.json(infos))); + var output = given() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) + .get("/info") + .then() + .contentType(APPLICATION_JSON) + .extract().as(ImageInfoDTO[].class); + + assertThat(output).hasSize(1); + } + + @Test + void testMaxUploadSize() { + + byte[] body = new byte[110001]; + new Random().nextBytes(body); + + var exception = given() + .when() + .auth().oauth2(keycloakClient.getAccessToken(ADMIN)) + .header(APM_HEADER_PARAM, ADMIN) + .body(body) + .contentType(MEDIA_TYPE_IMAGE_PNG) + .post() + .then() + .statusCode(BAD_REQUEST.getStatusCode()) + .extract().as(ProblemDetailResponseDTO.class); + + assertThat(exception.getDetail()).isEqualTo( + "createImage.contentLength: must be less than or equal to 110000"); + } + +} diff --git a/src/test/resources/images/Testimage.jpg b/src/test/resources/images/Testimage.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e663945812871d609c740a04b428faccc42c1129 GIT binary patch literal 31292 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