From c6df0562bff639e210cf0a26075c5fea331e83b4 Mon Sep 17 00:00:00 2001 From: thinhphamtx Date: Thu, 18 Jul 2024 15:24:58 +0700 Subject: [PATCH] [759]Create webhook --- .github/workflows/webhook-ci.yaml | 58 ++++ .../src/main/resources/application.yaml | 8 + backoffice/asset/data/sidebar.tsx | 6 + backoffice/common/components/Layout.tsx | 2 +- backoffice/constants/Common.ts | 1 + .../webhook/components/EventInformation.tsx | 84 ++++++ .../webhook/components/WebhookInformation.tsx | 47 ++++ .../modules/webhook/models/ContentType.ts | 3 + backoffice/modules/webhook/models/Event.ts | 4 + backoffice/modules/webhook/models/Webhook.ts | 10 + .../modules/webhook/services/EventService.ts | 7 + .../webhook/services/WebhookService.ts | 38 +++ backoffice/pages/webhook/[id]/edit.tsx | 101 +++++++ backoffice/pages/webhook/create.tsx | 70 +++++ backoffice/pages/webhook/index.tsx | 140 ++++++++++ backoffice/tsconfig.json | 5 +- identity/realm-export.json | 3 +- kafka/connects/debezium-order.json | 17 ++ kafka/connects/debezium-product.json | 3 +- nginx/templates/default.conf.template | 3 + .../db/changelog/ddl/changelog-0012.sql | 1 + pom.xml | 1 + postgres_init.sql | 2 + start-source-connectors.sh | 4 + webhook/.gitignore | 33 +++ webhook/.mvn/wrapper/maven-wrapper.properties | 19 ++ webhook/Dockerfile | 4 + webhook/mvnw | 259 ++++++++++++++++++ webhook/mvnw.cmd | 149 ++++++++++ webhook/pom.xml | 67 +++++ .../com/yas/webhook/WebhookApplication.java | 16 ++ .../com/yas/webhook/config/AsyncConfig.java | 11 + .../com/yas/webhook/config/CorsConfig.java | 21 ++ .../webhook/config/DatabaseAutoConfig.java | 30 ++ .../com/yas/webhook/config/KafkaConfig.java | 15 + .../yas/webhook/config/RestClientConfig.java | 14 + .../yas/webhook/config/SecurityConfig.java | 49 ++++ .../yas/webhook/config/ServiceUrlConfig.java | 7 + .../com/yas/webhook/config/SwaggerConfig.java | 16 ++ .../webhook/config/constants/ApiConstant.java | 18 ++ .../webhook/config/constants/MessageCode.java | 8 + .../config/constants/PageableConstant.java | 7 + .../config/exception/ApiExceptionHandler.java | 92 +++++++ .../config/exception/BadRequestException.java | 22 ++ .../config/exception/DuplicatedException.java | 22 ++ .../config/exception/NotFoundException.java | 23 ++ .../webhook/controller/EventController.java | 37 +++ .../webhook/controller/WebhookController.java | 87 ++++++ .../webhook/integration/api/WebhookApi.java | 30 ++ .../inbound/OrderEventInbound.java | 21 ++ .../inbound/ProductEventInbound.java | 20 ++ .../webhook/model/AbstractAuditEntity.java | 36 +++ .../model/CustomAuditingEntityListener.java | 38 +++ .../java/com/yas/webhook/model/Event.java | 26 ++ .../java/com/yas/webhook/model/Webhook.java | 31 +++ .../com/yas/webhook/model/WebhookEvent.java | 39 +++ .../webhook/model/enumeration/EventName.java | 7 + .../webhook/model/enumeration/Operation.java | 16 ++ .../yas/webhook/model/mapper/EventMapper.java | 12 + .../webhook/model/mapper/WebhookMapper.java | 62 +++++ .../model/viewmodel/error/ErrorVm.java | 11 + .../model/viewmodel/webhook/EventVm.java | 12 + .../viewmodel/webhook/WebhookDetailVm.java | 15 + .../viewmodel/webhook/WebhookListGetVm.java | 18 ++ .../viewmodel/webhook/WebhookPostVm.java | 14 + .../model/viewmodel/webhook/WebhookVm.java | 13 + .../webhook/repository/EventRepository.java | 16 ++ .../repository/WebhookEventRepository.java | 13 + .../webhook/repository/WebhookRepository.java | 9 + .../com/yas/webhook/service/EventService.java | 27 ++ .../webhook/service/OrderEventService.java | 54 ++++ .../webhook/service/ProductEventService.java | 40 +++ .../yas/webhook/service/WebhookService.java | 97 +++++++ .../java/com/yas/webhook/utils/HmacUtils.java | 21 ++ .../com/yas/webhook/utils/MessagesUtils.java | 26 ++ .../src/main/resources/application.properties | 49 ++++ .../db.changelog/db.changelog-master.yaml | 5 + .../db.changelog/ddl/changelog-0001.sql | 3 + webhook/src/main/resources/logback-spring.xml | 27 ++ .../resources/messages/messages.properties | 0 .../com/yas/webhook/ApplicationTests.java | 13 + 81 files changed, 2461 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/webhook-ci.yaml create mode 100644 backoffice/modules/webhook/components/EventInformation.tsx create mode 100644 backoffice/modules/webhook/components/WebhookInformation.tsx create mode 100644 backoffice/modules/webhook/models/ContentType.ts create mode 100644 backoffice/modules/webhook/models/Event.ts create mode 100644 backoffice/modules/webhook/models/Webhook.ts create mode 100644 backoffice/modules/webhook/services/EventService.ts create mode 100644 backoffice/modules/webhook/services/WebhookService.ts create mode 100644 backoffice/pages/webhook/[id]/edit.tsx create mode 100644 backoffice/pages/webhook/create.tsx create mode 100644 backoffice/pages/webhook/index.tsx create mode 100644 kafka/connects/debezium-order.json create mode 100644 order/src/main/resources/db/changelog/ddl/changelog-0012.sql create mode 100644 webhook/.gitignore create mode 100644 webhook/.mvn/wrapper/maven-wrapper.properties create mode 100644 webhook/Dockerfile create mode 100644 webhook/mvnw create mode 100644 webhook/mvnw.cmd create mode 100644 webhook/pom.xml create mode 100644 webhook/src/main/java/com/yas/webhook/WebhookApplication.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/AsyncConfig.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/CorsConfig.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/DatabaseAutoConfig.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/KafkaConfig.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/RestClientConfig.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/SecurityConfig.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/ServiceUrlConfig.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/SwaggerConfig.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/constants/ApiConstant.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/constants/MessageCode.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/constants/PageableConstant.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/exception/ApiExceptionHandler.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/exception/BadRequestException.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/exception/DuplicatedException.java create mode 100644 webhook/src/main/java/com/yas/webhook/config/exception/NotFoundException.java create mode 100644 webhook/src/main/java/com/yas/webhook/controller/EventController.java create mode 100644 webhook/src/main/java/com/yas/webhook/controller/WebhookController.java create mode 100644 webhook/src/main/java/com/yas/webhook/integration/api/WebhookApi.java create mode 100644 webhook/src/main/java/com/yas/webhook/integration/inbound/OrderEventInbound.java create mode 100644 webhook/src/main/java/com/yas/webhook/integration/inbound/ProductEventInbound.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/AbstractAuditEntity.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/CustomAuditingEntityListener.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/Event.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/Webhook.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/WebhookEvent.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/enumeration/EventName.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/enumeration/Operation.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/mapper/EventMapper.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/mapper/WebhookMapper.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/viewmodel/error/ErrorVm.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/EventVm.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookDetailVm.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookListGetVm.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookPostVm.java create mode 100644 webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookVm.java create mode 100644 webhook/src/main/java/com/yas/webhook/repository/EventRepository.java create mode 100644 webhook/src/main/java/com/yas/webhook/repository/WebhookEventRepository.java create mode 100644 webhook/src/main/java/com/yas/webhook/repository/WebhookRepository.java create mode 100644 webhook/src/main/java/com/yas/webhook/service/EventService.java create mode 100644 webhook/src/main/java/com/yas/webhook/service/OrderEventService.java create mode 100644 webhook/src/main/java/com/yas/webhook/service/ProductEventService.java create mode 100644 webhook/src/main/java/com/yas/webhook/service/WebhookService.java create mode 100644 webhook/src/main/java/com/yas/webhook/utils/HmacUtils.java create mode 100644 webhook/src/main/java/com/yas/webhook/utils/MessagesUtils.java create mode 100644 webhook/src/main/resources/application.properties create mode 100644 webhook/src/main/resources/db.changelog/db.changelog-master.yaml create mode 100644 webhook/src/main/resources/db.changelog/ddl/changelog-0001.sql create mode 100644 webhook/src/main/resources/logback-spring.xml create mode 100644 webhook/src/main/resources/messages/messages.properties create mode 100644 webhook/src/test/java/com/yas/webhook/ApplicationTests.java diff --git a/.github/workflows/webhook-ci.yaml b/.github/workflows/webhook-ci.yaml new file mode 100644 index 0000000000..ea493db430 --- /dev/null +++ b/.github/workflows/webhook-ci.yaml @@ -0,0 +1,58 @@ +name: webhook service ci + +on: + push: + branches: ["main"] + paths: + - "webhook/**" + - ".github/workflows/actions/action.yaml" + - ".github/workflows/webhook-ci.yaml" + pull_request: + branches: ["main"] + paths: + - "webhook/**" + - ".github/workflows/actions/action.yaml" + - ".github/workflows/webhook-ci.yaml" + + workflow_dispatch: + +jobs: + Build: + runs-on: ubuntu-latest + env: + FROM_ORIGINAL_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.ref == 'refs/heads/main' }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - uses: ./.github/workflows/actions + - name: Run Maven Build Command + run: mvn clean install -DskipTests -f webhook + - name: Run Maven Test + run: mvn test -f webhook + - name: Unit Test Results + uses: dorny/test-reporter@v1 + if: ${{ env.FROM_ORIGINAL_REPOSITORY == 'true' && (success() || failure()) }} + with: + name: Webhook-Service-Unit-Test-Results + path: "webhook/**/surefire-reports/*.xml" + reporter: java-junit + - name: Analyze with sonar cloud + if: ${{ env.FROM_ORIGINAL_REPOSITORY == 'true' }} + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: mvn org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -f webhook + - name: Log in to the Container registry + if: ${{ github.ref == 'refs/heads/main' }} + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push Docker images + if: ${{ github.ref == 'refs/heads/main' }} + uses: docker/build-push-action@v6 + with: + context: ./webhook + push: true + tags: ghcr.io/nashtech-garage/yas-webhook:latest \ No newline at end of file diff --git a/backoffice-bff/src/main/resources/application.yaml b/backoffice-bff/src/main/resources/application.yaml index 68b8c74f01..f577487b7a 100644 --- a/backoffice-bff/src/main/resources/application.yaml +++ b/backoffice-bff/src/main/resources/application.yaml @@ -43,6 +43,14 @@ spring: cloud: gateway: routes: + - id: api_webhook_local + uri: http://localhost:8092 + #uri: http://api.yas.local + predicates: + - Path=/api/webhook/** + filters: + - RewritePath=/api/(?.*), /$\{segment} + - TokenRelay= - id: api_product_local uri: http://localhost:8080 #uri: http://api.yas.local diff --git a/backoffice/asset/data/sidebar.tsx b/backoffice/asset/data/sidebar.tsx index 838a2d8d70..ced67ba72b 100644 --- a/backoffice/asset/data/sidebar.tsx +++ b/backoffice/asset/data/sidebar.tsx @@ -14,6 +14,7 @@ import { SALES_SHOPPING_CARTS_AND_WISHLISTS_URL, SYSTEM_PAYMENT_PROVIDERS, SYSTEM_SETTINGS, + WEBHOOKS_URL } from '@constants/Common'; export const menu_catalog_item_data = [ @@ -155,4 +156,9 @@ export const menu_system_item_data = [ name: 'Settings', link: SYSTEM_SETTINGS, }, + { + id: 3, + name: 'Webhooks', + link: WEBHOOKS_URL, + } ]; diff --git a/backoffice/common/components/Layout.tsx b/backoffice/common/components/Layout.tsx index ecbcce1d85..d49ff6ffdf 100644 --- a/backoffice/common/components/Layout.tsx +++ b/backoffice/common/components/Layout.tsx @@ -255,7 +255,7 @@ const Sidebar = (menu: MenuProps) => { -
  • changeMenu('system')}> +
  • changeMenu('system')}> ; + getValue: UseFormGetValues; +}; + +const EventInformation = ({ events, setValue, getValue: _getValue }: Props) => { + const [allEvents, setAllEvents] = useState([]); + let [latestCheckedEvent, setLatestCheckedEvent] = useState([]); + let listCheckEvent: WebhookEvent[] = []; + + useEffect(() => { + getEvents().then((data) => { + setAllEvents(data); + if (events !== undefined && latestCheckedEvent.length === 0) { + events.map((item: any) => { + latestCheckedEvent.push(item); + }); + setLatestCheckedEvent(latestCheckedEvent); + } + }); + }, []); + + function checkedTrue(id: number) { + const found = latestCheckedEvent.find((element) => element.id === id); + if (found === undefined) return false; + return true; + } + + const onUpdateCheckedEvent = (e: any) => { + const checkedEventId = Number(e.target.value); + if (e.target.checked) { + const webhookEvent = allEvents.find((element) => element.id === checkedEventId); + if (webhookEvent !== undefined) { + setLatestCheckedEvent([webhookEvent, ...latestCheckedEvent]); + latestCheckedEvent = [webhookEvent, ...latestCheckedEvent]; + } + } else { + latestCheckedEvent = latestCheckedEvent.filter((item) => item.id !== checkedEventId); + setLatestCheckedEvent(latestCheckedEvent); + } + setValue('events', latestCheckedEvent); + }; + + return ( +
    +
      + {allEvents.map((event, index) => ( +
    • + + +
    • + ))} +
    +
    + ); +}; + +export default EventInformation; diff --git a/backoffice/modules/webhook/components/WebhookInformation.tsx b/backoffice/modules/webhook/components/WebhookInformation.tsx new file mode 100644 index 0000000000..572aeab978 --- /dev/null +++ b/backoffice/modules/webhook/components/WebhookInformation.tsx @@ -0,0 +1,47 @@ +import { FieldErrorsImpl, UseFormRegister, UseFormSetValue, UseFormTrigger } from 'react-hook-form'; +import { CheckBox } from 'common/items/Input'; +import { Input } from 'common/items/Input'; +import { Webhook } from '../models/Webhook'; +import { ContentType } from '@webhookModels/ContentType'; + +type Props = { + register: UseFormRegister; + errors: FieldErrorsImpl; + setValue: UseFormSetValue; + trigger: UseFormTrigger; + webhook?: Webhook; +}; + +const WebhookInformation = ({ register, errors, setValue, trigger, webhook }: Props) => { + + return ( + <> + + + + + + ); +}; + +export default WebhookInformation; diff --git a/backoffice/modules/webhook/models/ContentType.ts b/backoffice/modules/webhook/models/ContentType.ts new file mode 100644 index 0000000000..b284b095b3 --- /dev/null +++ b/backoffice/modules/webhook/models/ContentType.ts @@ -0,0 +1,3 @@ +export enum ContentType { + APPLICATION_JSON = 'application/json' +} \ No newline at end of file diff --git a/backoffice/modules/webhook/models/Event.ts b/backoffice/modules/webhook/models/Event.ts new file mode 100644 index 0000000000..0c2cbea699 --- /dev/null +++ b/backoffice/modules/webhook/models/Event.ts @@ -0,0 +1,4 @@ +export type WebhookEvent = { + id: number; + name: string; +}; diff --git a/backoffice/modules/webhook/models/Webhook.ts b/backoffice/modules/webhook/models/Webhook.ts new file mode 100644 index 0000000000..c857e85788 --- /dev/null +++ b/backoffice/modules/webhook/models/Webhook.ts @@ -0,0 +1,10 @@ +import { WebhookEvent } from "./Event"; + +export type Webhook = { + id: number; + payloadUrl: string; + secret: string; + contentType: string; + isActive: boolean; + events: WebhookEvent[]; +}; diff --git a/backoffice/modules/webhook/services/EventService.ts b/backoffice/modules/webhook/services/EventService.ts new file mode 100644 index 0000000000..db5b958add --- /dev/null +++ b/backoffice/modules/webhook/services/EventService.ts @@ -0,0 +1,7 @@ +import { WebhookEvent } from '../models/Event'; + + +export async function getEvents(): Promise { + const response = await fetch('/api/webhook/backoffice/events'); + return await response.json(); +} diff --git a/backoffice/modules/webhook/services/WebhookService.ts b/backoffice/modules/webhook/services/WebhookService.ts new file mode 100644 index 0000000000..fd1d561a27 --- /dev/null +++ b/backoffice/modules/webhook/services/WebhookService.ts @@ -0,0 +1,38 @@ +import { Webhook } from '../models/Webhook'; + +export async function getWebhooks(pageNo: number, pageSize: number) { + const url = `/api/webhook/backoffice/webhooks/paging?pageNo=${pageNo}&pageSize=${pageSize}`; + const response = await fetch(url); + return await response.json(); +} + +export async function getWebhook(id: number): Promise { + const response = await fetch('/api/webhook/backoffice/webhooks/' + id); + return await response.json(); +} + +export async function createWebhook(webhook: Webhook) { + const response = await fetch('/api/webhook/backoffice/webhooks', { + method: 'POST', + body: JSON.stringify(webhook), + headers: { 'Content-type': 'application/json; charset=UTF-8' }, + }); + return response; +} +export async function updateWebhook(id: number, webhook: Webhook) { + const response = await fetch('/api/webhook/backoffice/webhooks/' + id, { + method: 'PUT', + body: JSON.stringify(webhook), + headers: { 'Content-type': 'application/json; charset=UTF-8' }, + }); + if (response.status === 204) return response; + else return await response.json(); +} +export async function deleteWebhook(id: number) { + const response = await fetch('/api/webhook/backoffice/webhooks/' + id, { + method: 'DELETE', + headers: { 'Content-type': 'application/json; charset=UTF-8' }, + }); + if (response.status === 204) return response; + else return await response.json(); +} diff --git a/backoffice/pages/webhook/[id]/edit.tsx b/backoffice/pages/webhook/[id]/edit.tsx new file mode 100644 index 0000000000..b492e940f5 --- /dev/null +++ b/backoffice/pages/webhook/[id]/edit.tsx @@ -0,0 +1,101 @@ +import { NextPage } from 'next'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; + +import { handleUpdatingResponse } from '@commonServices/ResponseStatusHandlingService'; +import { toastError } from '@commonServices/ToastService'; +import WebhookInformation from '@webhookComponents/WebhookInformation'; +import EventInformation from '@webhookComponents/EventInformation'; + +import { Webhook } from '@webhookModels/Webhook'; +import { updateWebhook, getWebhook } from '@webhookServices/WebhookService'; +import { WEBHOOKS_URL } from 'constants/Common'; + +const WebhookEdit: NextPage = () => { + const router = useRouter(); + const { + register, + handleSubmit, + formState: { errors }, + setValue, + getValues, + trigger, + } = useForm(); + const [webhook, setWebhook] = useState(); + const [isLoading, setLoading] = useState(false); + const { id } = router.query; + const handleSubmitEdit = async (webhook: Webhook) => { + if (id) { + let payload: Webhook = { + id: 0, + payloadUrl: webhook.payloadUrl, + contentType: webhook.contentType, + secret: webhook.secret, + isActive: webhook.isActive, + events: webhook.events + }; + + updateWebhook(+id, payload) + .then((response) => { + handleUpdatingResponse(response); + router.replace(WEBHOOKS_URL).catch((error) => console.log(error)); + }) + .catch((error) => console.log(error)); + } + }; + + useEffect(() => { + if (id) { + setLoading(true); + getWebhook(+id) + .then((data) => { + if (data.id) { + setWebhook(data); + setLoading(false); + } else { + toastError(data?.payloadUrl); + setLoading(false); + router.push(WEBHOOKS_URL).catch((error) => console.log(error)); + } + }) + .catch((error) => console.log(error)); + } + }, [id]); + + if (isLoading) return

    Loading...

    ; + if (!webhook) return <>; + return ( + <> +
    +
    +

    Edit Webhook: {id}

    +
    + + + + + + + + +
    +
    + + ); +}; + +export default WebhookEdit; diff --git a/backoffice/pages/webhook/create.tsx b/backoffice/pages/webhook/create.tsx new file mode 100644 index 0000000000..8cf56da06f --- /dev/null +++ b/backoffice/pages/webhook/create.tsx @@ -0,0 +1,70 @@ +import type { NextPage } from 'next'; +import { Webhook } from '@webhookModels/Webhook'; +import { createWebhook } from '@webhookServices/WebhookService'; +import React from 'react'; +import { useForm } from 'react-hook-form'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import WebhookInformation from '@webhookComponents/WebhookInformation'; +import { WEBHOOKS_URL } from 'constants/Common'; +import { handleCreatingResponse } from '@commonServices/ResponseStatusHandlingService'; +import EventInformation from '@webhookComponents/EventInformation'; +import { ContentType } from '@webhookModels/ContentType'; + +const WebhookCreate: NextPage = () => { + const router = useRouter(); + const { + register, + handleSubmit, + getValues, + formState: { errors }, + setValue, + trigger, + } = useForm(); + const handleSubmitWebhook = async (webhook: Webhook) => { + let payload: Webhook = { + id: 0, + payloadUrl: webhook.payloadUrl, + contentType: ContentType.APPLICATION_JSON, + secret: webhook.secret, + isActive: webhook.isActive, + events: webhook.events + }; + + let response = await createWebhook(payload); + handleCreatingResponse(response); + router.replace(WEBHOOKS_URL); + }; + + return ( + <> +
    +
    +

    Create Webhook

    +
    + + + + + + + + +
    +
    + + ); +}; + +export default WebhookCreate; diff --git a/backoffice/pages/webhook/index.tsx b/backoffice/pages/webhook/index.tsx new file mode 100644 index 0000000000..69168638fc --- /dev/null +++ b/backoffice/pages/webhook/index.tsx @@ -0,0 +1,140 @@ +import type { NextPage } from 'next'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { Button, Table } from 'react-bootstrap'; +import ReactPaginate from 'react-paginate'; + +import ModalDeleteCustom from '@commonItems/ModalDeleteCustom'; +import { handleDeletingResponse } from '@commonServices/ResponseStatusHandlingService'; +import type { Webhook } from '@webhookModels/Webhook'; +import { deleteWebhook, getWebhooks } from '@webhookServices/WebhookService'; +import { DEFAULT_PAGE_NUMBER, DEFAULT_PAGE_SIZE, WEBHOOKS_URL } from 'constants/Common'; + +const WebhookList: NextPage = () => { + const [webhookClassIdWantToDelete, setWebhookIdWantToDelete] = useState(-1); + const [webhookClassNameWantToDelete, setWebhookNameWantToDelete] = useState(''); + const [showModalDelete, setShowModalDelete] = useState(false); + const [webhooks, setWebhooks] = useState([]); + const [isLoading, setLoading] = useState(false); + const [pageNo, setPageNo] = useState(DEFAULT_PAGE_NUMBER); + const [totalPage, setTotalPage] = useState(1); + + const handleClose: any = () => setShowModalDelete(false); + const handleDelete: any = () => { + if (webhookClassIdWantToDelete == -1) { + return; + } + deleteWebhook(webhookClassIdWantToDelete) + .then((response) => { + setShowModalDelete(false); + handleDeletingResponse(response, webhookClassNameWantToDelete); + setPageNo(DEFAULT_PAGE_NUMBER); + getListWebhook(); + }) + .catch((error) => console.log(error)); + }; + + const getListWebhook = () => { + getWebhooks(pageNo, DEFAULT_PAGE_SIZE) + .then((data) => { + setTotalPage(data.totalPages); + setWebhooks(data.webhooks); + setLoading(false); + }) + .catch((error) => console.log(error)); + }; + + useEffect(() => { + setLoading(true); + getListWebhook(); + }, [pageNo]); + + const changePage = ({ selected }: any) => { + setPageNo(selected); + }; + + if (isLoading) return

    Loading...

    ; + if (!webhooks) return

    No Webhook

    ; + return ( + <> +
    +
    +

    Webhook

    +
    +
    + + + +
    +
    + + + + + + + + + + + {webhooks.map((webhook) => ( + + + + + + + + ))} + +
    #Payload UrlContent TypeStatus
    {webhook.id}{webhook.payloadUrl}{webhook.contentType} +
    + {webhook.isActive ? `ACTIVE` : `INACTIVE`} +
    +
    + + + +   + +
    + + + + ); +}; + +export default WebhookList; diff --git a/backoffice/tsconfig.json b/backoffice/tsconfig.json index d6f33dafa3..7e1aeddd23 100644 --- a/backoffice/tsconfig.json +++ b/backoffice/tsconfig.json @@ -16,7 +16,10 @@ "@catalogComponents/*": ["modules/catalog/components/*"], "@inventoryServices/*": ["modules/inventory/services/*"], "@inventoryModels/*": ["modules/inventory/models/*"], - "@inventoryComponents/*": ["modules/inventory/components/*"] + "@inventoryComponents/*": ["modules/inventory/components/*"], + "@webhookComponents/*": ["modules/webhook/components/*"], + "@webhookServices/*": ["modules/webhook/services/*"], + "@webhookModels/*": ["modules/webhook/models/*"] }, "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], diff --git a/identity/realm-export.json b/identity/realm-export.json index 66869d6a67..adcae6e36d 100644 --- a/identity/realm-export.json +++ b/identity/realm-export.json @@ -1137,7 +1137,8 @@ "http://localhost:8088/*", "http://localhost:8085/*", "http://localhost:8084/*", - "http://localhost:8086/*" + "http://localhost:8086/*", + "http://localhost:8092/*" ], "webOrigins": [ "http://api.yas.local", diff --git a/kafka/connects/debezium-order.json b/kafka/connects/debezium-order.json new file mode 100644 index 0000000000..e63bde20b1 --- /dev/null +++ b/kafka/connects/debezium-order.json @@ -0,0 +1,17 @@ +{ + "connector.class": "io.debezium.connector.postgresql.PostgresConnector", + "topic.prefix": "dborder", + "database.user": "admin", + "database.dbname": "order", + "database.hostname": "postgres", + "database.password": "admin", + "database.port": "5432", + "key.converter.schemas.enable": "false", + "value.converter.schemas.enable": "false", + "value.converter": "org.apache.kafka.connect.json.JsonConverter", + "key.converter": "org.apache.kafka.connect.json.JsonConverter", + "schema.include.list": "public", + "table.include.list": "public.order", + "column.include.list": "public.order.order_status", + "slot.name": "order_slot" +} \ No newline at end of file diff --git a/kafka/connects/debezium-product.json b/kafka/connects/debezium-product.json index 9394831631..f228d1bb53 100644 --- a/kafka/connects/debezium-product.json +++ b/kafka/connects/debezium-product.json @@ -11,5 +11,6 @@ "value.converter": "org.apache.kafka.connect.json.JsonConverter", "key.converter": "org.apache.kafka.connect.json.JsonConverter", "schema.include.list": "public", - "table.include.list": "public.product" + "table.include.list": "public.product", + "slot.name": "product_slot" } diff --git a/nginx/templates/default.conf.template b/nginx/templates/default.conf.template index 6609c079ed..0e1a4a037e 100644 --- a/nginx/templates/default.conf.template +++ b/nginx/templates/default.conf.template @@ -48,6 +48,9 @@ server { location /payment-paypal/ { proxy_pass http://payment-paypal; } + location /webhook/ { + proxy_pass http://webhook; + } } server { diff --git a/order/src/main/resources/db/changelog/ddl/changelog-0012.sql b/order/src/main/resources/db/changelog/ddl/changelog-0012.sql new file mode 100644 index 0000000000..0050641523 --- /dev/null +++ b/order/src/main/resources/db/changelog/ddl/changelog-0012.sql @@ -0,0 +1 @@ +ALTER TABLE "order" REPLICA IDENTITY FULL; \ No newline at end of file diff --git a/pom.xml b/pom.xml index e22d9dd7ac..842be73d31 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ search storefront-bff tax + webhook diff --git a/postgres_init.sql b/postgres_init.sql index 48dae0bca1..fc91f553d8 100644 --- a/postgres_init.sql +++ b/postgres_init.sql @@ -34,3 +34,5 @@ LIMIT = -1; CREATE DATABASE payment WITH OWNER = admin ENCODING = 'UTF8' LC_COLLATE = 'en_US.utf8' LC_CTYPE = 'en_US.utf8' TABLESPACE = pg_default CONNECTION LIMIT = -1; +CREATE DATABASE webhook WITH OWNER = admin ENCODING = 'UTF8' LC_COLLATE = 'en_US.utf8' LC_CTYPE = 'en_US.utf8' TABLESPACE = pg_default CONNECTION +LIMIT = -1; \ No newline at end of file diff --git a/start-source-connectors.sh b/start-source-connectors.sh index 3b6914578e..8cc7fcafde 100644 --- a/start-source-connectors.sh +++ b/start-source-connectors.sh @@ -2,3 +2,7 @@ curl -i -X PUT -H "Content-Type:application/json" \ http://localhost:8083/connectors/product-connector/config \ -d @kafka/connects/debezium-product.json + +curl -i -X PUT -H "Content-Type:application/json" \ + http://localhost:8083/connectors/order-connector/config \ + -d @kafka/connects/debezium-order.json \ No newline at end of file diff --git a/webhook/.gitignore b/webhook/.gitignore new file mode 100644 index 0000000000..549e00a2a9 --- /dev/null +++ b/webhook/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/webhook/.mvn/wrapper/maven-wrapper.properties b/webhook/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000..8f96f52c66 --- /dev/null +++ b/webhook/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.7/apache-maven-3.9.7-bin.zip diff --git a/webhook/Dockerfile b/webhook/Dockerfile new file mode 100644 index 0000000000..993edad1e9 --- /dev/null +++ b/webhook/Dockerfile @@ -0,0 +1,4 @@ +FROM eclipse-temurin:21-jre-alpine +COPY target/webhook*.jar app.jar +ENTRYPOINT ["java", "-jar", "/app.jar"] + diff --git a/webhook/mvnw b/webhook/mvnw new file mode 100644 index 0000000000..d7c358e5a2 --- /dev/null +++ b/webhook/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/webhook/mvnw.cmd b/webhook/mvnw.cmd new file mode 100644 index 0000000000..6f779cff20 --- /dev/null +++ b/webhook/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/webhook/pom.xml b/webhook/pom.xml new file mode 100644 index 0000000000..ec28b83186 --- /dev/null +++ b/webhook/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + com.yas + yas + ${revision} + ../pom.xml + + webhook + ${revision} + webhook + YAS Webhook service + + nashtech-garage + https://sonarcloud.io + nashtech-garage_yas-product + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-oauth2-authorization-server + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-aop + + + org.postgresql + postgresql + runtime + + + org.springframework.kafka + spring-kafka + 3.1.2 + + + org.mapstruct + mapstruct + 1.6.0.Beta1 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + diff --git a/webhook/src/main/java/com/yas/webhook/WebhookApplication.java b/webhook/src/main/java/com/yas/webhook/WebhookApplication.java new file mode 100644 index 0000000000..eb6edf36cc --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/WebhookApplication.java @@ -0,0 +1,16 @@ +package com.yas.webhook; + +import com.yas.webhook.config.ServiceUrlConfig; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication +@EnableConfigurationProperties(ServiceUrlConfig.class) +public class WebhookApplication { + + public static void main(String[] args) { + SpringApplication.run(WebhookApplication.class, args); + } + +} diff --git a/webhook/src/main/java/com/yas/webhook/config/AsyncConfig.java b/webhook/src/main/java/com/yas/webhook/config/AsyncConfig.java new file mode 100644 index 0000000000..17768e9e34 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/AsyncConfig.java @@ -0,0 +1,11 @@ +package com.yas.webhook.config; + +import org.springframework.beans.factory.annotation.Configurable; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfig { + +} diff --git a/webhook/src/main/java/com/yas/webhook/config/CorsConfig.java b/webhook/src/main/java/com/yas/webhook/config/CorsConfig.java new file mode 100644 index 0000000000..2fbcbb2ae6 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/CorsConfig.java @@ -0,0 +1,21 @@ +package com.yas.webhook.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig { + + @Bean + public WebMvcConfigurer corsConfigure() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**").allowedMethods("*").allowedOrigins("*") //NOSONAR + .allowedHeaders("*"); + } + }; + } +} diff --git a/webhook/src/main/java/com/yas/webhook/config/DatabaseAutoConfig.java b/webhook/src/main/java/com/yas/webhook/config/DatabaseAutoConfig.java new file mode 100644 index 0000000000..8852a783fc --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/DatabaseAutoConfig.java @@ -0,0 +1,30 @@ +package com.yas.webhook.config; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +@Configuration +@EnableJpaRepositories("com.yas.webhook.repository") +@EntityScan("com.yas.webhook.model") +@EnableJpaAuditing(auditorAwareRef = "auditorAware") +public class DatabaseAutoConfig { + + @Bean + public AuditorAware auditorAware() { + return () -> { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) { + return Optional.of(""); + } + return Optional.of(auth.getName()); + }; + } +} diff --git a/webhook/src/main/java/com/yas/webhook/config/KafkaConfig.java b/webhook/src/main/java/com/yas/webhook/config/KafkaConfig.java new file mode 100644 index 0000000000..dbe3654139 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/KafkaConfig.java @@ -0,0 +1,15 @@ +package com.yas.webhook.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.support.converter.ByteArrayJsonMessageConverter; +import org.springframework.kafka.support.converter.JsonMessageConverter; + +@Configuration +public class KafkaConfig { + + @Bean + public JsonMessageConverter jsonMessageConverter() { + return new ByteArrayJsonMessageConverter(); + } +} diff --git a/webhook/src/main/java/com/yas/webhook/config/RestClientConfig.java b/webhook/src/main/java/com/yas/webhook/config/RestClientConfig.java new file mode 100644 index 0000000000..0d010075a7 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/RestClientConfig.java @@ -0,0 +1,14 @@ +package com.yas.webhook.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient restClient() { + return RestClient.builder().build(); + } +} diff --git a/webhook/src/main/java/com/yas/webhook/config/SecurityConfig.java b/webhook/src/main/java/com/yas/webhook/config/SecurityConfig.java new file mode 100644 index 0000000000..7e794ec2ee --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/SecurityConfig.java @@ -0,0 +1,49 @@ +package com.yas.webhook.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.web.SecurityFilterChain; + +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; + +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/prometheus", "/actuator/health/**", + "/swagger-ui", "/swagger-ui/**", "/error", "/v3/api-docs/**").permitAll() + .requestMatchers("/storefront/**").permitAll() + .requestMatchers("/backoffice/**").hasRole("ADMIN") + .anyRequest().authenticated()) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) + .build(); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverterForKeycloak() { + Converter> jwtGrantedAuthoritiesConverter = jwt -> { + Map> realmAccess = jwt.getClaim("realm_access"); + Collection roles = realmAccess.get("roles"); + return roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .collect(Collectors.toList()); + }; + + var jwtAuthenticationConverter = new JwtAuthenticationConverter(); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter); + + return jwtAuthenticationConverter; + } +} diff --git a/webhook/src/main/java/com/yas/webhook/config/ServiceUrlConfig.java b/webhook/src/main/java/com/yas/webhook/config/ServiceUrlConfig.java new file mode 100644 index 0000000000..390be794e8 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/ServiceUrlConfig.java @@ -0,0 +1,7 @@ +package com.yas.webhook.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "yas.services") +public record ServiceUrlConfig(String location) { +} diff --git a/webhook/src/main/java/com/yas/webhook/config/SwaggerConfig.java b/webhook/src/main/java/com/yas/webhook/config/SwaggerConfig.java new file mode 100644 index 0000000000..b21b21d0d2 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/SwaggerConfig.java @@ -0,0 +1,16 @@ +package com.yas.webhook.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.*; +import io.swagger.v3.oas.annotations.servers.Server; + +@OpenAPIDefinition(info = @Info(title = "Webhook Service API", description = "Webhook API documentation", version = "1.0"), security = @SecurityRequirement(name = "oauth2_bearer"), + servers = {@Server(url = "${server.servlet.context-path}", description = "Default Server URL")}) +@SecurityScheme(name = "oauth2_bearer", type = SecuritySchemeType.OAUTH2, + flows = @OAuthFlows(authorizationCode = @OAuthFlow(authorizationUrl = "${springdoc.oauthflow.authorization-url}", tokenUrl = "${springdoc.oauthflow.token-url}", scopes = { + @OAuthScope(name = "openid", description = "openid") + }))) +public class SwaggerConfig { +} diff --git a/webhook/src/main/java/com/yas/webhook/config/constants/ApiConstant.java b/webhook/src/main/java/com/yas/webhook/config/constants/ApiConstant.java new file mode 100644 index 0000000000..0b78d274a9 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/constants/ApiConstant.java @@ -0,0 +1,18 @@ +package com.yas.webhook.config.constants; + +public final class ApiConstant { + public static final String WEBHOOK_URL = "/backoffice/webhooks"; + public static final String EVENT_URL = "/backoffice/events"; + + public static final String CODE_200 = "200"; + public static final String OK = "Ok"; + public static final String CODE_404 = "404"; + public static final String NOT_FOUND = "Not found"; + public static final String CODE_201 = "201"; + public static final String CREATED = "Created"; + public static final String CODE_400 = "400"; + public static final String BAD_REQUEST = "Bad request"; + public static final String CODE_204 = "204"; + public static final String NO_CONTENT = "No content"; +} + diff --git a/webhook/src/main/java/com/yas/webhook/config/constants/MessageCode.java b/webhook/src/main/java/com/yas/webhook/config/constants/MessageCode.java new file mode 100644 index 0000000000..b26b589953 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/constants/MessageCode.java @@ -0,0 +1,8 @@ +package com.yas.webhook.config.constants; + +public final class MessageCode { + + private MessageCode() {} + public static final String WEBHOOK_NOT_FOUND = "WEBHOOK_NOT_FOUND"; + public static final String EVENT_NOT_FOUND = "EVENT_NOT_FOUND"; +} diff --git a/webhook/src/main/java/com/yas/webhook/config/constants/PageableConstant.java b/webhook/src/main/java/com/yas/webhook/config/constants/PageableConstant.java new file mode 100644 index 0000000000..71dbfb4578 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/constants/PageableConstant.java @@ -0,0 +1,7 @@ +package com.yas.webhook.config.constants; + +public final class PageableConstant { + + public static final String DEFAULT_PAGE_SIZE = "10"; + public static final String DEFAULT_PAGE_NUMBER = "0"; +} diff --git a/webhook/src/main/java/com/yas/webhook/config/exception/ApiExceptionHandler.java b/webhook/src/main/java/com/yas/webhook/config/exception/ApiExceptionHandler.java new file mode 100644 index 0000000000..5d6cb2c75e --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/exception/ApiExceptionHandler.java @@ -0,0 +1,92 @@ +package com.yas.webhook.config.exception; + +import com.yas.webhook.model.viewmodel.error.ErrorVm; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.NestedExceptionUtils; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; + +import java.util.ArrayList; +import java.util.List; + +@ControllerAdvice +@Slf4j +public class ApiExceptionHandler { + + private static final String ERROR_LOG_FORMAT = "Error: URI: {}, ErrorCode: {}, Message: {}"; + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFoundException(NotFoundException ex, WebRequest request) { + String message = ex.getMessage(); + ErrorVm errorVm = new ErrorVm(HttpStatus.NOT_FOUND.toString(), + HttpStatus.NOT_FOUND.getReasonPhrase(), message); + log.warn(ERROR_LOG_FORMAT, this.getServletPath(request), 404, message); + log.debug(ex.toString()); + return new ResponseEntity<>(errorVm, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity handleBadRequestException(BadRequestException ex, + WebRequest request) { + String message = ex.getMessage(); + ErrorVm errorVm = new ErrorVm(HttpStatus.BAD_REQUEST.toString(), + HttpStatus.BAD_REQUEST.getReasonPhrase(), message); + return ResponseEntity.badRequest().body(errorVm); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex) { + List errors = ex.getBindingResult() + .getFieldErrors() + .stream() + .map(error -> error.getField() + " " + error.getDefaultMessage()) + .toList(); + + ErrorVm errorVm = new ErrorVm(HttpStatus.BAD_REQUEST.toString(), + HttpStatus.BAD_REQUEST.getReasonPhrase(), "Request information is not valid", errors); + return ResponseEntity.badRequest().body(errorVm); + } + + @ExceptionHandler({ConstraintViolationException.class}) + public ResponseEntity handleConstraintViolation(ConstraintViolationException ex) { + List errors = new ArrayList<>(); + for (ConstraintViolation violation : ex.getConstraintViolations()) { + errors.add(violation.getRootBeanClass().getName() + " " + + violation.getPropertyPath() + ": " + violation.getMessage()); + } + + ErrorVm errorVm = new ErrorVm(HttpStatus.BAD_REQUEST.toString(), + HttpStatus.BAD_REQUEST.getReasonPhrase(), "Request information is not valid", errors); + return ResponseEntity.badRequest().body(errorVm); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolationException( + DataIntegrityViolationException e) { + String message = NestedExceptionUtils.getMostSpecificCause(e).getMessage(); + ErrorVm errorVm = new ErrorVm(HttpStatus.BAD_REQUEST.toString(), + HttpStatus.BAD_REQUEST.getReasonPhrase(), message); + return ResponseEntity.badRequest().body(errorVm); + } + + @ExceptionHandler(DuplicatedException.class) + protected ResponseEntity handleDuplicated(DuplicatedException e) { + ErrorVm errorVm = new ErrorVm(HttpStatus.BAD_REQUEST.toString(), + HttpStatus.BAD_REQUEST.getReasonPhrase(), e.getMessage()); + return ResponseEntity.badRequest().body(errorVm); + } + + private String getServletPath(WebRequest webRequest) { + ServletWebRequest servletRequest = (ServletWebRequest) webRequest; + return servletRequest.getRequest().getServletPath(); + } +} diff --git a/webhook/src/main/java/com/yas/webhook/config/exception/BadRequestException.java b/webhook/src/main/java/com/yas/webhook/config/exception/BadRequestException.java new file mode 100644 index 0000000000..35d675b521 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/exception/BadRequestException.java @@ -0,0 +1,22 @@ +package com.yas.webhook.config.exception; + + +import com.yas.webhook.utils.MessagesUtils; + +public class BadRequestException extends RuntimeException { + + private String message; + + public BadRequestException(String errorCode, Object... var2) { + this.message = MessagesUtils.getMessage(errorCode, var2); + } + + @Override + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/webhook/src/main/java/com/yas/webhook/config/exception/DuplicatedException.java b/webhook/src/main/java/com/yas/webhook/config/exception/DuplicatedException.java new file mode 100644 index 0000000000..325a68be34 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/exception/DuplicatedException.java @@ -0,0 +1,22 @@ +package com.yas.webhook.config.exception; + + +import com.yas.webhook.utils.MessagesUtils; + +public class DuplicatedException extends RuntimeException { + + private String message; + + public DuplicatedException(String errorCode, Object... var2) { + this.message = MessagesUtils.getMessage(errorCode, var2); + } + + @Override + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/webhook/src/main/java/com/yas/webhook/config/exception/NotFoundException.java b/webhook/src/main/java/com/yas/webhook/config/exception/NotFoundException.java new file mode 100644 index 0000000000..4d3dab566a --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/config/exception/NotFoundException.java @@ -0,0 +1,23 @@ +package com.yas.webhook.config.exception; + + +import com.yas.webhook.utils.MessagesUtils; + +public class NotFoundException extends RuntimeException { + + private String message; + + public NotFoundException(String errorCode, Object... var2) { + this.message = MessagesUtils.getMessage(errorCode, var2); + } + + @Override + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} + diff --git a/webhook/src/main/java/com/yas/webhook/controller/EventController.java b/webhook/src/main/java/com/yas/webhook/controller/EventController.java new file mode 100644 index 0000000000..79e790f4d2 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/controller/EventController.java @@ -0,0 +1,37 @@ +package com.yas.webhook.controller; + +import com.yas.webhook.config.constants.ApiConstant; +import com.yas.webhook.config.constants.PageableConstant; +import com.yas.webhook.model.viewmodel.error.ErrorVm; +import com.yas.webhook.model.viewmodel.webhook.EventVm; +import com.yas.webhook.model.viewmodel.webhook.WebhookListGetVm; +import com.yas.webhook.model.viewmodel.webhook.WebhookPostVm; +import com.yas.webhook.model.viewmodel.webhook.WebhookVm; +import com.yas.webhook.service.EventService; +import com.yas.webhook.service.WebhookService; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.List; + +@RestController +@RequestMapping(ApiConstant.EVENT_URL) +public class EventController { + private final EventService eventService; + + public EventController(EventService eventService) { + this.eventService = eventService; + } + + @GetMapping + public ResponseEntity> listWebhooks() { + return ResponseEntity.ok(eventService.findAllEvents()); + } + +} diff --git a/webhook/src/main/java/com/yas/webhook/controller/WebhookController.java b/webhook/src/main/java/com/yas/webhook/controller/WebhookController.java new file mode 100644 index 0000000000..61f0757037 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/controller/WebhookController.java @@ -0,0 +1,87 @@ +package com.yas.webhook.controller; + +import com.yas.webhook.config.constants.ApiConstant; +import com.yas.webhook.config.constants.PageableConstant; +import com.yas.webhook.model.viewmodel.error.ErrorVm; +import com.yas.webhook.model.viewmodel.webhook.WebhookDetailVm; +import com.yas.webhook.model.viewmodel.webhook.WebhookListGetVm; +import com.yas.webhook.model.viewmodel.webhook.WebhookPostVm; +import com.yas.webhook.model.viewmodel.webhook.WebhookVm; +import com.yas.webhook.service.WebhookService; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.List; + +@RestController +@RequestMapping(ApiConstant.WEBHOOK_URL) +public class WebhookController { + private final WebhookService webhookService; + + public WebhookController(WebhookService webhookService) { + this.webhookService = webhookService; + } + + @GetMapping("/paging") + public ResponseEntity getPageableWebhooks( + @RequestParam(value = "pageNo", defaultValue = PageableConstant.DEFAULT_PAGE_NUMBER, required = false) final int pageNo, + @RequestParam(value = "pageSize", defaultValue = PageableConstant.DEFAULT_PAGE_SIZE, required = false) final int pageSize) { + return ResponseEntity.ok(webhookService.getPageableWebhooks(pageNo, pageSize)); + } + + @GetMapping + public ResponseEntity> listWebhooks() { + return ResponseEntity.ok(webhookService.findAllWebhooks()); + } + + @GetMapping("/{id}") + @ApiResponses(value = { + @ApiResponse(responseCode = ApiConstant.CODE_200, description = ApiConstant.OK, content = @Content(schema = @Schema(implementation = WebhookVm.class))), + @ApiResponse(responseCode = ApiConstant.CODE_404, description = ApiConstant.NOT_FOUND, content = @Content(schema = @Schema(implementation = ErrorVm.class)))}) + public ResponseEntity getWebhook(@PathVariable("id") final Long id) { + return ResponseEntity.ok(webhookService.findById(id)); + } + + @PostMapping + @ApiResponses(value = { + @ApiResponse(responseCode = ApiConstant.CODE_201, description = ApiConstant.CREATED, content = @Content(schema = @Schema(implementation = WebhookVm.class))), + @ApiResponse(responseCode = ApiConstant.CODE_400, description = ApiConstant.BAD_REQUEST, content = @Content(schema = @Schema(implementation = ErrorVm.class)))}) + public ResponseEntity createWebhook( + @Valid @RequestBody final WebhookPostVm webhookPostVm, + final UriComponentsBuilder uriComponentsBuilder) { + WebhookDetailVm webhook = webhookService.create(webhookPostVm); + return ResponseEntity.created( + uriComponentsBuilder + .replacePath("/webhooks/{id}") + .buildAndExpand(webhook) + .toUri()) + .body(webhook); + } + + @PutMapping("/{id}") + @ApiResponses(value = { + @ApiResponse(responseCode = ApiConstant.CODE_204, description = ApiConstant.NO_CONTENT, content = @Content()), + @ApiResponse(responseCode = ApiConstant.CODE_404, description = ApiConstant.NOT_FOUND, content = @Content(schema = @Schema(implementation = ErrorVm.class))), + @ApiResponse(responseCode = ApiConstant.CODE_400, description = ApiConstant.BAD_REQUEST, content = @Content(schema = @Schema(implementation = ErrorVm.class)))}) + public ResponseEntity updateWebhook(@PathVariable final Long id, + @Valid @RequestBody final WebhookPostVm webhookPostVm) { + webhookService.update(webhookPostVm, id); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{id}") + @ApiResponses(value = { + @ApiResponse(responseCode = ApiConstant.CODE_204, description = ApiConstant.NO_CONTENT, content = @Content()), + @ApiResponse(responseCode = ApiConstant.CODE_404, description = ApiConstant.NOT_FOUND, content = @Content(schema = @Schema(implementation = ErrorVm.class))), + @ApiResponse(responseCode = ApiConstant.CODE_400, description = ApiConstant.BAD_REQUEST, content = @Content(schema = @Schema(implementation = ErrorVm.class)))}) + public ResponseEntity deleteWebhook(@PathVariable(name = "id") final Long id) { + webhookService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/webhook/src/main/java/com/yas/webhook/integration/api/WebhookApi.java b/webhook/src/main/java/com/yas/webhook/integration/api/WebhookApi.java new file mode 100644 index 0000000000..978a054ab5 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/integration/api/WebhookApi.java @@ -0,0 +1,30 @@ +package com.yas.webhook.integration.api; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yas.webhook.utils.HmacUtils; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +@RequiredArgsConstructor +public class WebhookApi { + + public static final String X_HUB_SIGNATURE_256 = "X-Hub-Signature-256"; + + private final RestClient restClient; + + @SneakyThrows + public void notify(String url, String secret, JsonNode jsonNode) { + + String secretToken = HmacUtils.hash(jsonNode.toString(), secret); + + restClient.post() + .uri(url) + .header(X_HUB_SIGNATURE_256, secretToken) + .body(jsonNode) + .retrieve() + .toBodilessEntity(); + } +} diff --git a/webhook/src/main/java/com/yas/webhook/integration/inbound/OrderEventInbound.java b/webhook/src/main/java/com/yas/webhook/integration/inbound/OrderEventInbound.java new file mode 100644 index 0000000000..2f186a1f37 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/integration/inbound/OrderEventInbound.java @@ -0,0 +1,21 @@ +package com.yas.webhook.integration.inbound; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yas.webhook.service.OrderEventService; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OrderEventInbound { + + private final OrderEventService orderEventService; + + @KafkaListener(topics = { + "${webhook.integration.kafka.order.topic-name}"}, groupId = "${spring.kafka.consumer.group-id}") + public void onOrderEvent(JsonNode productEvent) { + orderEventService.onOrderEvent(productEvent); + } + +} diff --git a/webhook/src/main/java/com/yas/webhook/integration/inbound/ProductEventInbound.java b/webhook/src/main/java/com/yas/webhook/integration/inbound/ProductEventInbound.java new file mode 100644 index 0000000000..2b99500a39 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/integration/inbound/ProductEventInbound.java @@ -0,0 +1,20 @@ +package com.yas.webhook.integration.inbound; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yas.webhook.service.ProductEventService; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ProductEventInbound { + + private final ProductEventService productEventService; + + @KafkaListener(topics = { + "${webhook.integration.kafka.product.topic-name}"}, groupId = "${spring.kafka.consumer.group-id}") + public void onProductEvent(JsonNode productEvent) { + productEventService.onProductEvent(productEvent); + } +} diff --git a/webhook/src/main/java/com/yas/webhook/model/AbstractAuditEntity.java b/webhook/src/main/java/com/yas/webhook/model/AbstractAuditEntity.java new file mode 100644 index 0000000000..09f53cfa83 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/AbstractAuditEntity.java @@ -0,0 +1,36 @@ +package com.yas.webhook.model; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.LastModifiedBy; + +import java.time.ZonedDateTime; + +@MappedSuperclass +@Getter +@Setter +@EntityListeners(CustomAuditingEntityListener.class) +public class AbstractAuditEntity { + + @CreationTimestamp + @Column(name = "created_on") + private ZonedDateTime createdOn; + + @CreatedBy + @Column(name = "created_by") + private String createdBy; + + @UpdateTimestamp + @Column(name = "last_modified_on") + private ZonedDateTime lastModifiedOn; + + @LastModifiedBy + @Column(name = "last_modified_by") + private String lastModifiedBy; +} diff --git a/webhook/src/main/java/com/yas/webhook/model/CustomAuditingEntityListener.java b/webhook/src/main/java/com/yas/webhook/model/CustomAuditingEntityListener.java new file mode 100644 index 0000000000..7a553c441b --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/CustomAuditingEntityListener.java @@ -0,0 +1,38 @@ +package com.yas.webhook.model; + +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.beans.factory.annotation.Configurable; +import org.springframework.data.auditing.AuditingHandler; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Configurable +public class CustomAuditingEntityListener extends AuditingEntityListener { + + public CustomAuditingEntityListener(ObjectFactory handler) { + super.setAuditingHandler(handler); + } + + @Override + @PrePersist + public void touchForCreate(Object target) { + AbstractAuditEntity entity = (AbstractAuditEntity) target; + if (entity.getCreatedBy() == null) { + super.touchForCreate(target); + } else { + if (entity.getLastModifiedBy() == null) { + entity.setLastModifiedBy(entity.getCreatedBy()); + } + } + } + + @Override + @PreUpdate + public void touchForUpdate(Object target) { + AbstractAuditEntity entity = (AbstractAuditEntity) target; + if (entity.getLastModifiedBy() == null) { + super.touchForUpdate(target); + } + } +} diff --git a/webhook/src/main/java/com/yas/webhook/model/Event.java b/webhook/src/main/java/com/yas/webhook/model/Event.java new file mode 100644 index 0000000000..de45c5e73e --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/Event.java @@ -0,0 +1,26 @@ +package com.yas.webhook.model; + +import com.yas.webhook.model.enumeration.EventName; +import jakarta.persistence.*; +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "event") +@Getter +@Setter +@NoArgsConstructor +public class Event { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Enumerated(EnumType.STRING) + private EventName name; + private String description; + + @OneToMany(fetch = FetchType.LAZY, mappedBy = "event") + private List webhookEvents; +} diff --git a/webhook/src/main/java/com/yas/webhook/model/Webhook.java b/webhook/src/main/java/com/yas/webhook/model/Webhook.java new file mode 100644 index 0000000000..2eb94c80a4 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/Webhook.java @@ -0,0 +1,31 @@ +package com.yas.webhook.model; + +import jakarta.persistence.*; + +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "webhook") +@Getter +@Setter +@NoArgsConstructor +public class Webhook extends AbstractAuditEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "payload_url") + private String payloadUrl; + @Column(name = "content_type") + private String contentType; + @Column(name = "secret") + private String secret; + @Column(name = "is_active") + private Boolean isActive; + + @OneToMany(fetch = FetchType.LAZY, mappedBy = "webhook") + List webhookEvents; + +} diff --git a/webhook/src/main/java/com/yas/webhook/model/WebhookEvent.java b/webhook/src/main/java/com/yas/webhook/model/WebhookEvent.java new file mode 100644 index 0000000000..d6c393d3af --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/WebhookEvent.java @@ -0,0 +1,39 @@ +package com.yas.webhook.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "webhook_event") +@Getter +@Setter +@NoArgsConstructor +public class WebhookEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "webhook_id") + private Long webhookId; + + @Column(name = "event_id") + private Long eventId; + + @ManyToOne + @JoinColumn(name = "webhook_id", updatable = false, insertable = false) + private Webhook webhook; + + @ManyToOne + @JoinColumn(name = "event_id", updatable = false, insertable = false) + private Event event; +} diff --git a/webhook/src/main/java/com/yas/webhook/model/enumeration/EventName.java b/webhook/src/main/java/com/yas/webhook/model/enumeration/EventName.java new file mode 100644 index 0000000000..b8ee1e50f4 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/enumeration/EventName.java @@ -0,0 +1,7 @@ +package com.yas.webhook.model.enumeration; + +public enum EventName { + ON_PRODUCT_UPDATED, + ON_ORDER_CREATED, + ON_ORDER_STATUS_UPDATED; +} diff --git a/webhook/src/main/java/com/yas/webhook/model/enumeration/Operation.java b/webhook/src/main/java/com/yas/webhook/model/enumeration/Operation.java new file mode 100644 index 0000000000..52330c8ebc --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/enumeration/Operation.java @@ -0,0 +1,16 @@ +package com.yas.webhook.model.enumeration; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum Operation { + + UPDATE("u"), + CREATE("c"), + DELETE("d"), + READ("r"); + + final String name; +} diff --git a/webhook/src/main/java/com/yas/webhook/model/mapper/EventMapper.java b/webhook/src/main/java/com/yas/webhook/model/mapper/EventMapper.java new file mode 100644 index 0000000000..235f55eec0 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/mapper/EventMapper.java @@ -0,0 +1,12 @@ +package com.yas.webhook.model.mapper; + +import com.yas.webhook.model.Event; +import com.yas.webhook.model.viewmodel.webhook.EventVm; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface EventMapper { + + EventVm toEventVm(Event event); + +} diff --git a/webhook/src/main/java/com/yas/webhook/model/mapper/WebhookMapper.java b/webhook/src/main/java/com/yas/webhook/model/mapper/WebhookMapper.java new file mode 100644 index 0000000000..ea64e5c027 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/mapper/WebhookMapper.java @@ -0,0 +1,62 @@ +package com.yas.webhook.model.mapper; + +import com.yas.webhook.model.Webhook; +import com.yas.webhook.model.WebhookEvent; +import com.yas.webhook.model.viewmodel.webhook.*; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import org.mapstruct.Named; +import org.springframework.data.domain.Page; +import org.springframework.util.CollectionUtils; + +import java.util.Collections; +import java.util.List; + +@Mapper(componentModel = "spring") +public interface WebhookMapper { + + WebhookVm toWebhookVm(Webhook webhook); + + @Named("toWebhookEventVms") + default List toWebhookEventVms(List webhookEvents){ + return CollectionUtils.isEmpty(webhookEvents) ? Collections.emptyList() :webhookEvents.stream().map(webhookEvent + -> EventVm.builder() + .id(webhookEvent.getEventId()) + .build()).toList(); + } + + default WebhookListGetVm toWebhookListGetVm(Page webhooks, int pageNo, int pageSize) { + return WebhookListGetVm.builder() + .webhooks(webhooks.stream().map(this::toWebhookVm).toList()) + .pageNo(pageNo) + .pageSize(pageSize) + .totalPages(webhooks.getTotalPages()) + .totalElements( webhooks.getTotalElements()) + .isLast( webhooks.isLast()).build(); + } + + @Mapping(target = "id", ignore = true) + @Mapping(target = "payloadUrl", source = "webhookPostVm.payloadUrl") + @Mapping(target = "contentType", ignore = true) + @Mapping(target = "secret", source = "webhookPostVm.secret") + @Mapping(target = "isActive", source = "webhookPostVm.isActive") + @Mapping(target = "webhookEvents", ignore = true) + @Mapping(target = "createdOn", ignore = true) + @Mapping(target = "createdBy", ignore = true) + @Mapping(target = "lastModifiedOn", ignore = true) + @Mapping(target = "lastModifiedBy", ignore = true) + Webhook toUpdatedWebhook(@MappingTarget Webhook webhook, WebhookPostVm webhookPostVm); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "webhookEvents", ignore = true) + @Mapping(target = "createdOn", ignore = true) + @Mapping(target = "createdBy", ignore = true) + @Mapping(target = "lastModifiedOn", ignore = true) + @Mapping(target = "lastModifiedBy", ignore = true) + Webhook toCreatedWebhook(WebhookPostVm webhookPostVm); + + @Mapping(target = "events", source = "webhookEvents", qualifiedByName = "toWebhookEventVms") + @Mapping(target = "secret", ignore = true) + WebhookDetailVm toWebhookDetailVm(Webhook createdWebhook); +} diff --git a/webhook/src/main/java/com/yas/webhook/model/viewmodel/error/ErrorVm.java b/webhook/src/main/java/com/yas/webhook/model/viewmodel/error/ErrorVm.java new file mode 100644 index 0000000000..4a57eb1b71 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/viewmodel/error/ErrorVm.java @@ -0,0 +1,11 @@ +package com.yas.webhook.model.viewmodel.error; + +import java.util.ArrayList; +import java.util.List; + +public record ErrorVm(String statusCode, String title, String detail, List fieldErrors) { + + public ErrorVm(String statusCode, String title, String detail) { + this(statusCode, title, detail, new ArrayList()); + } +} diff --git a/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/EventVm.java b/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/EventVm.java new file mode 100644 index 0000000000..f46af019ea --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/EventVm.java @@ -0,0 +1,12 @@ +package com.yas.webhook.model.viewmodel.webhook; + +import com.yas.webhook.model.enumeration.EventName; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class EventVm { + long id; + EventName name; +} diff --git a/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookDetailVm.java b/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookDetailVm.java new file mode 100644 index 0000000000..c54bce6e9f --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookDetailVm.java @@ -0,0 +1,15 @@ +package com.yas.webhook.model.viewmodel.webhook; + +import lombok.Data; + +import java.util.List; + +@Data +public class WebhookDetailVm { + Long id; + String payloadUrl; + String secret; + String contentType; + Boolean isActive; + List events; +} diff --git a/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookListGetVm.java b/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookListGetVm.java new file mode 100644 index 0000000000..a08062dbe9 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookListGetVm.java @@ -0,0 +1,18 @@ +package com.yas.webhook.model.viewmodel.webhook; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class WebhookListGetVm { + List webhooks; + int pageNo; + int pageSize; + long totalElements; + long totalPages; + boolean isLast; +} + diff --git a/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookPostVm.java b/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookPostVm.java new file mode 100644 index 0000000000..ebf6091396 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookPostVm.java @@ -0,0 +1,14 @@ +package com.yas.webhook.model.viewmodel.webhook; + +import lombok.Data; + +import java.util.List; + +@Data +public class WebhookPostVm { + String payloadUrl; + String secret; + String contentType; + Boolean isActive; + List events; +} diff --git a/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookVm.java b/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookVm.java new file mode 100644 index 0000000000..56eb9e920e --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/model/viewmodel/webhook/WebhookVm.java @@ -0,0 +1,13 @@ +package com.yas.webhook.model.viewmodel.webhook; + +import lombok.Data; + +import java.util.List; + +@Data +public class WebhookVm { + Long id; + String payloadUrl; + String contentType; + Boolean isActive; +} diff --git a/webhook/src/main/java/com/yas/webhook/repository/EventRepository.java b/webhook/src/main/java/com/yas/webhook/repository/EventRepository.java new file mode 100644 index 0000000000..54a51d3aad --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/repository/EventRepository.java @@ -0,0 +1,16 @@ +package com.yas.webhook.repository; + +import com.yas.webhook.model.Event; +import com.yas.webhook.model.enumeration.EventName; +import java.util.Optional; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface EventRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"webhookEvents.webhook"}) + Optional findByName(EventName name); + +} diff --git a/webhook/src/main/java/com/yas/webhook/repository/WebhookEventRepository.java b/webhook/src/main/java/com/yas/webhook/repository/WebhookEventRepository.java new file mode 100644 index 0000000000..dd74dab321 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/repository/WebhookEventRepository.java @@ -0,0 +1,13 @@ +package com.yas.webhook.repository; + +import com.yas.webhook.model.WebhookEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface WebhookEventRepository extends JpaRepository { + + void deleteByWebhookId(Long webhookId); +} diff --git a/webhook/src/main/java/com/yas/webhook/repository/WebhookRepository.java b/webhook/src/main/java/com/yas/webhook/repository/WebhookRepository.java new file mode 100644 index 0000000000..6e5d2fa3ad --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/repository/WebhookRepository.java @@ -0,0 +1,9 @@ +package com.yas.webhook.repository; + +import com.yas.webhook.model.Webhook; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface WebhookRepository extends JpaRepository { +} diff --git a/webhook/src/main/java/com/yas/webhook/service/EventService.java b/webhook/src/main/java/com/yas/webhook/service/EventService.java new file mode 100644 index 0000000000..7d6c55f320 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/service/EventService.java @@ -0,0 +1,27 @@ +package com.yas.webhook.service; + +import com.yas.webhook.model.Event; +import com.yas.webhook.model.mapper.EventMapper; +import com.yas.webhook.model.viewmodel.webhook.EventVm; +import com.yas.webhook.repository.EventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +public class EventService { + + private final EventRepository eventRepository; + private final EventMapper eventMapper; + + public List findAllEvents() { + List events = eventRepository.findAll(Sort.by(Sort.Direction.DESC, "id")); + return events.stream().map(eventMapper::toEventVm).toList(); + } + +} diff --git a/webhook/src/main/java/com/yas/webhook/service/OrderEventService.java b/webhook/src/main/java/com/yas/webhook/service/OrderEventService.java new file mode 100644 index 0000000000..bb45faf6cd --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/service/OrderEventService.java @@ -0,0 +1,54 @@ +package com.yas.webhook.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yas.webhook.config.constants.MessageCode; +import com.yas.webhook.config.exception.NotFoundException; +import com.yas.webhook.model.Event; +import com.yas.webhook.model.WebhookEvent; +import com.yas.webhook.model.enumeration.EventName; +import com.yas.webhook.model.enumeration.Operation; +import com.yas.webhook.repository.EventRepository; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OrderEventService { + + private final EventRepository eventRepository; + private final WebhookService webhookService; + + public void onOrderEvent(JsonNode updatedEvent) { + Optional optionalEventName = getEventName(updatedEvent); + if (optionalEventName.isPresent()) { + Event event = eventRepository.findByName(optionalEventName.get()) + .orElseThrow(() -> new NotFoundException(MessageCode.EVENT_NOT_FOUND, optionalEventName.get())); + List hookEvents = event.getWebhookEvents(); + hookEvents.forEach(hookEvent -> { + String url = hookEvent.getWebhook().getPayloadUrl(); + String secret = hookEvent.getWebhook().getSecret(); + JsonNode payload = updatedEvent.get("after"); + + webhookService.notifyToWebhook(url, secret, payload); + }); + } + } + + private Optional getEventName(JsonNode updatedEvent) { + String operation = updatedEvent.get("op").asText(); + if (Objects.equals(operation, Operation.CREATE.getName())) { + return Optional.of(EventName.ON_ORDER_CREATED); + } + if (Objects.equals(operation, Operation.UPDATE.getName())) { + String orderStatusBefore = updatedEvent.path("before").get("order_status").asText(); + String orderStatusAfter = updatedEvent.path("after").get("order_status").asText(); + if (!orderStatusBefore.equals(orderStatusAfter)) { + return Optional.of(EventName.ON_ORDER_STATUS_UPDATED); + } + } + return Optional.empty(); + } +} diff --git a/webhook/src/main/java/com/yas/webhook/service/ProductEventService.java b/webhook/src/main/java/com/yas/webhook/service/ProductEventService.java new file mode 100644 index 0000000000..7b380f0989 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/service/ProductEventService.java @@ -0,0 +1,40 @@ +package com.yas.webhook.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yas.webhook.config.constants.MessageCode; +import com.yas.webhook.config.exception.NotFoundException; +import com.yas.webhook.model.Event; +import com.yas.webhook.model.WebhookEvent; +import com.yas.webhook.model.enumeration.EventName; +import com.yas.webhook.model.enumeration.Operation; +import com.yas.webhook.repository.EventRepository; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ProductEventService { + + private final EventRepository eventRepository; + private final WebhookService webhookService; + + public void onProductEvent(JsonNode updatedEvent) { + String operation = updatedEvent.get("op").asText(); + if (!Objects.equals(operation, Operation.UPDATE.getName())) { + return; + } + Event event = eventRepository.findByName(EventName.ON_PRODUCT_UPDATED) + .orElseThrow(() -> new NotFoundException(MessageCode.EVENT_NOT_FOUND, EventName.ON_PRODUCT_UPDATED)); + List hookEvents = event.getWebhookEvents(); + hookEvents.forEach(hookEvent -> { + String url = hookEvent.getWebhook().getPayloadUrl(); + String secret = hookEvent.getWebhook().getSecret(); + JsonNode payload = updatedEvent.get("after"); + + webhookService.notifyToWebhook(url, secret, payload); + }); + } + +} diff --git a/webhook/src/main/java/com/yas/webhook/service/WebhookService.java b/webhook/src/main/java/com/yas/webhook/service/WebhookService.java new file mode 100644 index 0000000000..321aae35a9 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/service/WebhookService.java @@ -0,0 +1,97 @@ +package com.yas.webhook.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.yas.webhook.config.constants.MessageCode; +import com.yas.webhook.config.exception.NotFoundException; +import com.yas.webhook.integration.api.WebhookApi; +import com.yas.webhook.model.Webhook; +import com.yas.webhook.model.WebhookEvent; +import com.yas.webhook.model.mapper.WebhookMapper; +import com.yas.webhook.model.viewmodel.webhook.*; +import com.yas.webhook.repository.EventRepository; +import com.yas.webhook.repository.WebhookEventRepository; +import com.yas.webhook.repository.WebhookRepository; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +@Service +@Transactional +@RequiredArgsConstructor +public class WebhookService { + + private final WebhookRepository webhookRepository; + private final EventRepository eventRepository; + private final WebhookEventRepository webhookEventRepository; + private final WebhookMapper webhookMapper; + private final WebhookApi webHookApi; + + public WebhookListGetVm getPageableWebhooks(int pageNo, int pageSize) { + PageRequest pageRequest = PageRequest.of(pageNo, pageSize, Sort.by(Sort.Direction.DESC, "id")); + Page webhooks = webhookRepository.findAll(pageRequest); + return webhookMapper.toWebhookListGetVm(webhooks, pageNo, pageSize); + } + + public List findAllWebhooks() { + List webhooks = webhookRepository.findAll(Sort.by(Sort.Direction.DESC, "id")); + return webhooks.stream().map(webhookMapper::toWebhookVm).toList(); + } + + public WebhookDetailVm findById(Long id) { + return webhookMapper.toWebhookDetailVm(webhookRepository.findById(id).orElseThrow(() -> new NotFoundException(MessageCode.WEBHOOK_NOT_FOUND, id))); + } + + public WebhookDetailVm create(WebhookPostVm webhookPostVm) { + Webhook createdWebhook = webhookMapper.toCreatedWebhook(webhookPostVm); + createdWebhook = webhookRepository.save(createdWebhook); + if (!CollectionUtils.isEmpty(webhookPostVm.getEvents())) { + List webhookEvents = initializeWebhookEvents(createdWebhook.getId(), webhookPostVm.getEvents()); + webhookEvents = webhookEventRepository.saveAll(webhookEvents); + createdWebhook.setWebhookEvents(webhookEvents); + } + return webhookMapper.toWebhookDetailVm(createdWebhook); + } + + public void update(WebhookPostVm webhookPostVm, Long id) { + Webhook existedWebHook = webhookRepository.findById(id).orElseThrow(() -> new NotFoundException(MessageCode.WEBHOOK_NOT_FOUND, id)); + Webhook updatedWebhook = webhookMapper.toUpdatedWebhook(existedWebHook, webhookPostVm); + webhookRepository.save(updatedWebhook); + webhookEventRepository.deleteAll(existedWebHook.getWebhookEvents().stream().toList()); + if (!CollectionUtils.isEmpty(webhookPostVm.getEvents())) { + List webhookEvents = initializeWebhookEvents(id, webhookPostVm.getEvents()); + webhookEventRepository.saveAll(webhookEvents); + } + } + + public void delete(Long id) { + if (!webhookRepository.existsById(id)) { + throw new NotFoundException(MessageCode.WEBHOOK_NOT_FOUND, id); + } + webhookEventRepository.deleteByWebhookId(id); + webhookRepository.deleteById(id); + } + + @Async + public void notifyToWebhook(String url, String secret, JsonNode payload) { + webHookApi.notify(url, secret, payload); + } + + private List initializeWebhookEvents(Long webhookId, List events) { + return events.stream().map(hookEventVm -> { + WebhookEvent webhookEvent = new WebhookEvent(); + webhookEvent.setWebhookId(webhookId); + long eventId = hookEventVm.getId(); + eventRepository.findById(eventId) + .orElseThrow(() -> new NotFoundException(MessageCode.EVENT_NOT_FOUND, eventId)); + webhookEvent.setEventId(eventId); + return webhookEvent; + }).toList(); + } +} diff --git a/webhook/src/main/java/com/yas/webhook/utils/HmacUtils.java b/webhook/src/main/java/com/yas/webhook/utils/HmacUtils.java new file mode 100644 index 0000000000..77a9b031e9 --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/utils/HmacUtils.java @@ -0,0 +1,21 @@ +package com.yas.webhook.utils; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class HmacUtils { + + public static final String HMAC_SHA_256 = "HmacSHA256"; + + public static String hash(String data, String key) throws NoSuchAlgorithmException, InvalidKeyException { + SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), HMAC_SHA_256); + Mac mac = Mac.getInstance(HMAC_SHA_256); + mac.init(secretKeySpec); + + return new String(mac.doFinal(data.getBytes())); + } +} diff --git a/webhook/src/main/java/com/yas/webhook/utils/MessagesUtils.java b/webhook/src/main/java/com/yas/webhook/utils/MessagesUtils.java new file mode 100644 index 0000000000..2e830d4d8e --- /dev/null +++ b/webhook/src/main/java/com/yas/webhook/utils/MessagesUtils.java @@ -0,0 +1,26 @@ +package com.yas.webhook.utils; + +import org.slf4j.helpers.FormattingTuple; +import org.slf4j.helpers.MessageFormatter; + +import java.util.Locale; +import java.util.MissingResourceException; +import java.util.ResourceBundle; + +public class MessagesUtils { + + static ResourceBundle messageBundle = ResourceBundle.getBundle("messages.messages", + Locale.getDefault()); + + public static String getMessage(String errorCode, Object... var2) { + String message; + try { + message = messageBundle.getString(errorCode); + } catch (MissingResourceException ex) { + // case message_code is not defined. + message = errorCode; + } + FormattingTuple formattingTuple = MessageFormatter.arrayFormat(message, var2); + return formattingTuple.getMessage(); + } +} diff --git a/webhook/src/main/resources/application.properties b/webhook/src/main/resources/application.properties new file mode 100644 index 0000000000..c355138326 --- /dev/null +++ b/webhook/src/main/resources/application.properties @@ -0,0 +1,49 @@ +server.port=8092 +server.servlet.context-path=/webhook + +spring.application.name=webhook +spring.threads.virtual.enabled=true + +management.tracing.sampling.probability=1.0 +management.endpoints.web.exposure.include=prometheus +management.metrics.distribution.percentiles-histogram.http.server.requests=true +management.metrics.tags.application=${spring.application.name} + +logging.pattern.level=%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}] + +yas.services.webhook=http://api.yas.local/webhook + +spring.security.oauth2.resourceserver.jwt.issuer-uri=http://identity/realms/Yas + +spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.url=jdbc:postgresql://localhost:5432/webhook +spring.datasource.username=admin +spring.datasource.password=admin + +# The SQL dialect makes Hibernate generate better SQL for the chosen database +spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.PostgreSQLDialect + +# Hibernate ddl auto (none, create, create-drop, validate, update) +spring.jpa.hibernate.ddl-auto = none + +#Enable liquibase +spring.liquibase.enabled=true + +spring.kafka.bootstrap-servers=kafka:9092 +spring.kafka.consumer.group-id=webhook +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.ByteArrayDeserializer + +spring.kafka.consumer.properties.spring.json.use.type.headers=false + +webhook.integration.kafka.product.topic-name=dbproduct.public.product +webhook.integration.kafka.order.topic-name=dborder.public.order + +# swagger-ui custom path +springdoc.swagger-ui.path=/swagger-ui +springdoc.packagesToScan=com.yas.webhook +springdoc.swagger-ui.oauth.use-pkce-with-authorization-code-grant=true +springdoc.swagger-ui.oauth.client-id=swagger-ui +springdoc.oauthflow.authorization-url=http://identity/realms/Yas/protocol/openid-connect/auth +springdoc.oauthflow.token-url=http://identity/realms/Yas/protocol/openid-connect/token + diff --git a/webhook/src/main/resources/db.changelog/db.changelog-master.yaml b/webhook/src/main/resources/db.changelog/db.changelog-master.yaml new file mode 100644 index 0000000000..6b3639fb5c --- /dev/null +++ b/webhook/src/main/resources/db.changelog/db.changelog-master.yaml @@ -0,0 +1,5 @@ +databaseChangeLog: + - includeAll: + path: db/changelog/ddl/ + - includeAll: + path: db/changelog/data/ diff --git a/webhook/src/main/resources/db.changelog/ddl/changelog-0001.sql b/webhook/src/main/resources/db.changelog/ddl/changelog-0001.sql new file mode 100644 index 0000000000..bc7bfad1b8 --- /dev/null +++ b/webhook/src/main/resources/db.changelog/ddl/changelog-0001.sql @@ -0,0 +1,3 @@ +create table webhook (id bigserial not null, payload_url text not null, content_type varchar(128), secret varchar(512), is_active bool, created_by varchar(256), created_on timestamp(6), last_modified_by varchar(255), last_modified_on timestamp(6), primary key (id)); +create table event (id bigserial not null, name varchar(512) not null, description text, primary key (id)); +create table webhook_event (id bigserial not null, webhook_id bigserial references webhook(id), event_id bigserial references event(id), primary key (id)); \ No newline at end of file diff --git a/webhook/src/main/resources/logback-spring.xml b/webhook/src/main/resources/logback-spring.xml new file mode 100644 index 0000000000..e9b3c157ca --- /dev/null +++ b/webhook/src/main/resources/logback-spring.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + ${user-system}.out + + ${CONSOLE_LOG_PATTERN} + ${ENCODING} + + + + + + + diff --git a/webhook/src/main/resources/messages/messages.properties b/webhook/src/main/resources/messages/messages.properties new file mode 100644 index 0000000000..e69de29bb2 diff --git a/webhook/src/test/java/com/yas/webhook/ApplicationTests.java b/webhook/src/test/java/com/yas/webhook/ApplicationTests.java new file mode 100644 index 0000000000..d0c3957f5d --- /dev/null +++ b/webhook/src/test/java/com/yas/webhook/ApplicationTests.java @@ -0,0 +1,13 @@ +package com.yas.webhook; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ApplicationTests { + + @Test + void contextLoads() { + } + +}