From b0a0fe829cc7d399046b8de471f993127ef16e74 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Thu, 14 Mar 2024 11:40:23 +0100 Subject: [PATCH] feat: add initial version of aam-backend-service (#1) --- .github/workflows/aam-backend-service.yml | 48 ++ README.md | 7 + application/aam-backend-service/.editorconfig | 176 ++++ application/aam-backend-service/.gitignore | 40 + application/aam-backend-service/Dockerfile | 5 + .../aam-backend-service/build.gradle.kts | 87 ++ .../aam-backend-service/detekt-config.yml | 805 ++++++++++++++++++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + application/aam-backend-service/gradlew | 249 ++++++ application/aam-backend-service/gradlew.bat | 92 ++ .../aam-backend-service/settings.gradle.kts | 1 + .../aambackendservice/Application.kt | 15 + .../couchdb/core/CouchDbClient.kt | 189 ++++ .../couchdb/core/CouchDbHelper.kt | 13 + .../couchdb/core/CouchDbStorage.kt | 46 + .../couchdb/di/CouchDbConfiguration.kt | 42 + .../couchdb/dto/CouchDbDto.kt | 54 ++ .../crypto/core/CryptoService.kt | 66 ++ .../crypto/di/CryptoConfiguration.kt | 16 + .../domain/DomainReference.kt | 18 + .../aambackendservice/domain/EntityType.kt | 16 + .../aambackendservice/error/AamException.kt | 48 ++ .../queue/core/DefaultQueueMessageParser.kt | 83 ++ .../queue/core/QueueMessage.kt | 12 + .../queue/core/QueueMessageParser.kt | 9 + .../queue/di/QueueConfiguration.kt | 20 + .../changes/core/ChangeEventPublisher.kt | 10 + .../core/CouchDbDatabaseChangeDetection.kt | 94 ++ .../core/CreateDocumentChangeUseCase.kt | 8 + .../changes/core/DatabaseChangeDetection.kt | 7 + .../core/DatabaseChangeEventConsumer.kt | 9 + .../DefaultCreateDocumentChangeUseCase.kt | 76 ++ .../core/NoopDatabaseChangeDetection.kt | 16 + .../changes/di/ChangesConfiguration.kt | 54 ++ .../changes/di/ChangesQueueConfiguration.kt | 70 ++ .../changes/di/RepositoryConfiguration.kt | 29 + .../changes/jobs/CouchDbChangeDetectionJob.kt | 47 + .../queue/DefaultChangeEventPublisher.kt | 89 ++ .../DefaultDatabaseChangeEventConsumer.kt | 60 ++ .../changes/repository/SyncRepository.kt | 16 + .../reporting/domain/Report.kt | 9 + .../reporting/domain/ReportCalculation.kt | 57 ++ .../reporting/domain/ReportData.kt | 21 + .../reporting/domain/ReportSchema.kt | 5 + .../domain/event/DatabaseChangeEvent.kt | 8 + .../domain/event/DocumentChangeEvent.kt | 10 + .../domain/event/NotificationEvent.kt | 7 + .../controller/WebhookController.kt | 136 +++ .../core/AddWebhookSubscriptionUseCase.kt | 8 + .../DefaultAddWebhookSubscriptionUseCase.kt | 45 + .../core/DefaultNotificationEventConsumer.kt | 58 ++ .../core/DefaultNotificationEventPublisher.kt | 56 ++ .../core/DefaultTriggerWebhookUseCase.kt | 68 ++ .../notification/core/DefaultUriParser.kt | 13 + .../core/NotificationEventConsumer.kt | 9 + .../core/NotificationEventPublisher.kt | 8 + .../notification/core/NotificationService.kt | 55 ++ .../notification/core/NotificationStorage.kt | 22 + .../core/TriggerWebhookUseCase.kt | 8 + .../reporting/notification/core/UriParser.kt | 5 + .../di/NotificationConfiguration.kt | 63 ++ .../di/NotificationQueueConfiguration.kt | 64 ++ .../reporting/notification/dto/Dto.kt | 27 + .../storage/DefaultNotificationStorage.kt | 103 +++ .../notification/storage/WebhookEntity.kt | 26 + .../notification/storage/WebhookRepository.kt | 66 ++ .../DefaultIdentifyAffectedReportsUseCase.kt | 35 + .../core/DefaultReportCalculationProcessor.kt | 69 ++ ...efaultReportDocumentChangeEventConsumer.kt | 99 +++ .../core/IdentifyAffectedReportsUseCase.kt | 9 + .../core/NoopReportCalculationProcessor.kt | 9 + .../reporting/report/core/QueryStorage.kt | 9 + .../report/core/ReportCalculationProcessor.kt | 7 + .../core/ReportDocumentChangeEventConsumer.kt | 9 + .../report/core/ReportSchemaGenerator.kt | 8 + .../reporting/report/core/ReportingStorage.kt | 26 + .../core/SimpleReportSchemaGenerator.kt | 29 + .../report/di/ReportConfiguration.kt | 46 + .../report/di/ReportQueueConfiguration.kt | 49 ++ .../reporting/report/di/SqsConfiguration.kt | 31 + .../reporting/report/dto/ControllerDtos.kt | 39 + .../report/jobs/ReportCalculationJob.kt | 23 + .../reporting/report/sqs/SqsQueryStorage.kt | 56 ++ .../reporting/report/sqs/SqsSchemaService.kt | 201 +++++ .../report/storage/DefaultReportingStorage.kt | 150 ++++ .../storage/ReportCalculationRepository.kt | 113 +++ .../report/storage/ReportRepository.kt | 36 + .../controller/ReportCalculationController.kt | 92 ++ .../controller/ReportController.kt | 67 ++ .../core/CreateReportCalculationUseCase.kt | 35 + .../DefaultCreateReportCalculationUseCase.kt | 45 + .../DefaultReportCalculationChangeUseCase.kt | 54 ++ .../core/DefaultReportCalculator.kt | 44 + .../core/ReportCalculationChangeUseCase.kt | 8 + .../core/ReportCalculator.kt | 9 + .../di/ReportCalculationConfiguration.kt | 21 + .../rest/AamErrorAttributes.kt | 77 ++ .../security/SecurityConfiguration.kt | 61 ++ .../src/main/resources/application.yaml | 77 ++ .../sql/embedded_h2_database_init_script.sql | 9 + .../aambackendservice/ApplicationTests.kt | 14 + docs/api-specs/reporting-api-v1.yaml | 566 ++++++++++++ docs/assets/keycloak-client-setup.png | Bin 0 -> 52877 bytes docs/modules/reporting.md | 58 ++ 105 files changed, 5996 insertions(+) create mode 100644 .github/workflows/aam-backend-service.yml create mode 100644 README.md create mode 100644 application/aam-backend-service/.editorconfig create mode 100644 application/aam-backend-service/.gitignore create mode 100644 application/aam-backend-service/Dockerfile create mode 100644 application/aam-backend-service/build.gradle.kts create mode 100644 application/aam-backend-service/detekt-config.yml create mode 100644 application/aam-backend-service/gradle/wrapper/gradle-wrapper.jar create mode 100644 application/aam-backend-service/gradle/wrapper/gradle-wrapper.properties create mode 100755 application/aam-backend-service/gradlew create mode 100644 application/aam-backend-service/gradlew.bat create mode 100644 application/aam-backend-service/settings.gradle.kts create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/Application.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbClient.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbHelper.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbStorage.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/di/CouchDbConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/dto/CouchDbDto.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/crypto/core/CryptoService.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/crypto/di/CryptoConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/domain/DomainReference.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/domain/EntityType.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/error/AamException.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/DefaultQueueMessageParser.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/QueueMessage.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/QueueMessageParser.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/di/QueueConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/ChangeEventPublisher.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CouchDbDatabaseChangeDetection.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CreateDocumentChangeUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeDetection.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeEventConsumer.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DefaultCreateDocumentChangeUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/NoopDatabaseChangeDetection.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesQueueConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/RepositoryConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/jobs/CouchDbChangeDetectionJob.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultChangeEventPublisher.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultDatabaseChangeEventConsumer.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/Report.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/ReportCalculation.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/ReportData.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/ReportSchema.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DatabaseChangeEvent.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DocumentChangeEvent.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/NotificationEvent.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/controller/WebhookController.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/AddWebhookSubscriptionUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultAddWebhookSubscriptionUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventConsumer.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventPublisher.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultTriggerWebhookUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultUriParser.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationEventConsumer.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationEventPublisher.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationService.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationStorage.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/TriggerWebhookUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/UriParser.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationQueueConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/dto/Dto.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/DefaultNotificationStorage.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/WebhookEntity.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/WebhookRepository.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultIdentifyAffectedReportsUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportCalculationProcessor.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumer.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/IdentifyAffectedReportsUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/NoopReportCalculationProcessor.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/QueryStorage.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportCalculationProcessor.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportDocumentChangeEventConsumer.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportSchemaGenerator.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportingStorage.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/SimpleReportSchemaGenerator.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/ReportConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/ReportQueueConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/SqsConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/dto/ControllerDtos.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/jobs/ReportCalculationJob.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsQueryStorage.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsSchemaService.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/storage/DefaultReportingStorage.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/storage/ReportCalculationRepository.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/storage/ReportRepository.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/controller/ReportCalculationController.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/controller/ReportController.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/CreateReportCalculationUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultCreateReportCalculationUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculationChangeUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculator.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculationChangeUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculator.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/di/ReportCalculationConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/rest/AamErrorAttributes.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt create mode 100644 application/aam-backend-service/src/main/resources/application.yaml create mode 100644 application/aam-backend-service/src/main/resources/sql/embedded_h2_database_init_script.sql create mode 100644 application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/ApplicationTests.kt create mode 100644 docs/api-specs/reporting-api-v1.yaml create mode 100644 docs/assets/keycloak-client-setup.png create mode 100644 docs/modules/reporting.md diff --git a/.github/workflows/aam-backend-service.yml b/.github/workflows/aam-backend-service.yml new file mode 100644 index 0000000..84d63ed --- /dev/null +++ b/.github/workflows/aam-backend-service.yml @@ -0,0 +1,48 @@ +name: aam-backend-service + +on: + push: + branches: + - main + - 'tw/feat/query-backend-port' + paths: + - '.github/workflows/aam-backend-service.yml' + - 'application/aam-backend-service/**' + workflow_dispatch: + +jobs: + push_to_registry: + name: Build aam-backend-service and publish to container registry + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: 17 + cache: 'gradle' + + - name: Build application + working-directory: application/aam-backend-service + run: ./gradlew installDist + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + platforms: linux/amd64 + context: ./application/aam-backend-service + push: true + tags: ghcr.io/aam-digital/aam-backend-service:latest diff --git a/README.md b/README.md new file mode 100644 index 0000000..87d56f8 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Aam Digital Backend Services + +A modularize Spring Boot application that contains API modules for [Aam Digital's case management platform](https://github.com/Aam-Digital/ndb-core). + +## API Modules + +- **Reporting**: Calculate aggregated reports and run queries on all data, accessible for external services for API integrations of systems diff --git a/application/aam-backend-service/.editorconfig b/application/aam-backend-service/.editorconfig new file mode 100644 index 0000000..0d7b8bc --- /dev/null +++ b/application/aam-backend-service/.editorconfig @@ -0,0 +1,176 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +tab_width = 4 +ij_continuation_indent_size = 8 +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on +ij_formatter_tags_enabled = true +ij_smart_tabs = false +ij_visual_guides = +ij_wrap_on_typing = false + +[{*.bash,*.sh,*.zsh}] +indent_size = 2 +tab_width = 2 +ij_shell_binary_ops_start_line = false +ij_shell_keep_column_alignment_padding = false +ij_shell_minify_program = false +ij_shell_redirect_followed_by_space = false +ij_shell_switch_cases_indented = false +ij_shell_use_unix_line_separator = true + +[{*.gradle.kts,*.kt,*.kts,*.main.kts,*.space.kts}] +ij_kotlin_align_in_columns_case_branch = false +ij_kotlin_align_multiline_binary_operation = false +ij_kotlin_align_multiline_extends_list = false +ij_kotlin_align_multiline_method_parentheses = false +ij_kotlin_align_multiline_parameters = true +ij_kotlin_align_multiline_parameters_in_calls = false +ij_kotlin_allow_trailing_comma = false +ij_kotlin_allow_trailing_comma_on_call_site = false +ij_kotlin_assignment_wrap = normal +ij_kotlin_blank_lines_after_class_header = 0 +ij_kotlin_blank_lines_around_block_when_branches = 0 +ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 +ij_kotlin_block_comment_add_space = false +ij_kotlin_block_comment_at_first_column = true +ij_kotlin_call_parameters_new_line_after_left_paren = true +ij_kotlin_call_parameters_right_paren_on_new_line = true +ij_kotlin_call_parameters_wrap = on_every_item +ij_kotlin_catch_on_new_line = false +ij_kotlin_class_annotation_wrap = split_into_lines +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_continuation_indent_for_chained_calls = false +ij_kotlin_continuation_indent_for_expression_bodies = false +ij_kotlin_continuation_indent_in_argument_lists = false +ij_kotlin_continuation_indent_in_elvis = false +ij_kotlin_continuation_indent_in_if_conditions = false +ij_kotlin_continuation_indent_in_parameter_lists = false +ij_kotlin_continuation_indent_in_supertype_lists = false +ij_kotlin_else_on_new_line = false +ij_kotlin_enum_constants_wrap = off +ij_kotlin_extends_list_wrap = normal +ij_kotlin_field_annotation_wrap = split_into_lines +ij_kotlin_finally_on_new_line = false +ij_kotlin_if_rparen_on_new_line = true +ij_kotlin_import_nested_classes = false +ij_kotlin_imports_layout = *, java.**, javax.**, kotlin.**, ^ +ij_kotlin_insert_whitespaces_in_simple_one_line_method = true +ij_kotlin_keep_blank_lines_before_right_brace = 2 +ij_kotlin_keep_blank_lines_in_code = 2 +ij_kotlin_keep_blank_lines_in_declarations = 2 +ij_kotlin_keep_first_column_comment = true +ij_kotlin_keep_indents_on_empty_lines = false +ij_kotlin_keep_line_breaks = true +ij_kotlin_lbrace_on_next_line = false +ij_kotlin_line_break_after_multiline_when_entry = true +ij_kotlin_line_comment_add_space = false +ij_kotlin_line_comment_add_space_on_reformat = false +ij_kotlin_line_comment_at_first_column = true +ij_kotlin_method_annotation_wrap = split_into_lines +ij_kotlin_method_call_chain_wrap = normal +ij_kotlin_method_parameters_new_line_after_left_paren = true +ij_kotlin_method_parameters_right_paren_on_new_line = true +ij_kotlin_method_parameters_wrap = on_every_item +ij_kotlin_name_count_to_use_star_import = 999 +ij_kotlin_name_count_to_use_star_import_for_members = 999 +ij_kotlin_packages_to_use_import_on_demand = java.util.*, kotlinx.android.synthetic.**, io.ktor.** +ij_kotlin_parameter_annotation_wrap = off +ij_kotlin_space_after_comma = true +ij_kotlin_space_after_extend_colon = true +ij_kotlin_space_after_type_colon = true +ij_kotlin_space_before_catch_parentheses = true +ij_kotlin_space_before_comma = false +ij_kotlin_space_before_extend_colon = true +ij_kotlin_space_before_for_parentheses = true +ij_kotlin_space_before_if_parentheses = true +ij_kotlin_space_before_lambda_arrow = true +ij_kotlin_space_before_type_colon = false +ij_kotlin_space_before_when_parentheses = true +ij_kotlin_space_before_while_parentheses = true +ij_kotlin_spaces_around_additive_operators = true +ij_kotlin_spaces_around_assignment_operators = true +ij_kotlin_spaces_around_equality_operators = true +ij_kotlin_spaces_around_function_type_arrow = true +ij_kotlin_spaces_around_logical_operators = true +ij_kotlin_spaces_around_multiplicative_operators = true +ij_kotlin_spaces_around_range = false +ij_kotlin_spaces_around_relational_operators = true +ij_kotlin_spaces_around_unary_operator = false +ij_kotlin_spaces_around_when_arrow = true +ij_kotlin_variable_annotation_wrap = off +ij_kotlin_while_on_new_line = false +ij_kotlin_wrap_elvis_expressions = 1 +ij_kotlin_wrap_expression_body_functions = 1 +ij_kotlin_wrap_first_method_in_call_chain = false + +[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,bowerrc,jest.config}] +indent_size = 2 +ij_json_array_wrapping = split_into_lines +ij_json_keep_blank_lines_in_code = 0 +ij_json_keep_indents_on_empty_lines = false +ij_json_keep_line_breaks = true +ij_json_keep_trailing_comma = true +ij_json_object_wrapping = split_into_lines +ij_json_property_alignment = do_not_align +ij_json_space_after_colon = true +ij_json_space_after_comma = true +ij_json_space_before_colon = false +ij_json_space_before_comma = false +ij_json_spaces_within_braces = false +ij_json_spaces_within_brackets = false +ij_json_wrap_long_lines = false + +[{*.markdown,*.md}] +ij_markdown_force_one_space_after_blockquote_symbol = true +ij_markdown_force_one_space_after_header_symbol = true +ij_markdown_force_one_space_after_list_bullet = true +ij_markdown_force_one_space_between_words = true +ij_markdown_format_tables = true +ij_markdown_insert_quote_arrows_on_wrap = true +ij_markdown_keep_indents_on_empty_lines = false +ij_markdown_keep_line_breaks_inside_text_blocks = true +ij_markdown_max_lines_around_block_elements = 1 +ij_markdown_max_lines_around_header = 1 +ij_markdown_max_lines_between_paragraphs = 1 +ij_markdown_min_lines_around_block_elements = 1 +ij_markdown_min_lines_around_header = 1 +ij_markdown_min_lines_between_paragraphs = 1 +ij_markdown_wrap_text_if_long = true +ij_markdown_wrap_text_inside_blockquotes = true + +[{*.tf,*.tfvars}] +indent_size = 2 +ij_hcl-terraform_array_wrapping = normal +ij_hcl-terraform_keep_blank_lines_in_code = 2 +ij_hcl-terraform_keep_indents_on_empty_lines = false +ij_hcl-terraform_keep_line_breaks = true +ij_hcl-terraform_object_wrapping = normal +ij_hcl-terraform_property_alignment = 2 +ij_hcl-terraform_property_line_commenter_character = 1 +ij_hcl-terraform_space_after_comma = true +ij_hcl-terraform_space_before_comma = false +ij_hcl-terraform_spaces_around_assignment_operators = true +ij_hcl-terraform_spaces_within_braces = false +ij_hcl-terraform_spaces_within_brackets = false +ij_hcl-terraform_wrap_long_lines = false + +[{*.yaml,*.yaml.tpl,*.yml}] +indent_size = 2 +ij_yaml_align_values_properties = do_not_align +ij_yaml_autoinsert_sequence_marker = true +ij_yaml_block_mapping_on_new_line = false +ij_yaml_indent_sequence_value = true +ij_yaml_keep_indents_on_empty_lines = false +ij_yaml_keep_line_breaks = true +ij_yaml_sequence_on_new_line = false +ij_yaml_space_before_colon = false +ij_yaml_spaces_within_braces = true +ij_yaml_spaces_within_brackets = true diff --git a/application/aam-backend-service/.gitignore b/application/aam-backend-service/.gitignore new file mode 100644 index 0000000..5a979af --- /dev/null +++ b/application/aam-backend-service/.gitignore @@ -0,0 +1,40 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Kotlin ### +.kotlin diff --git a/application/aam-backend-service/Dockerfile b/application/aam-backend-service/Dockerfile new file mode 100644 index 0000000..7ddcd71 --- /dev/null +++ b/application/aam-backend-service/Dockerfile @@ -0,0 +1,5 @@ +FROM amazoncorretto:17-alpine +COPY ./build/install/aam-backend-service /aam-backend-service +WORKDIR /aam-backend-service/bin +EXPOSE 8080 +CMD ./aam-backend-service diff --git a/application/aam-backend-service/build.gradle.kts b/application/aam-backend-service/build.gradle.kts new file mode 100644 index 0000000..8358bf7 --- /dev/null +++ b/application/aam-backend-service/build.gradle.kts @@ -0,0 +1,87 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + application + distribution + jacoco + id("org.springframework.boot") version "3.2.3" + id("io.spring.dependency-management") version "1.1.4" + id("io.sentry.jvm.gradle") version "4.3.1" + kotlin("kapt") version "1.9.22" + kotlin("jvm") version "1.9.22" + kotlin("plugin.spring") version "1.9.22" +} + +group = "com.aam-digital" +version = "0.0.1-SNAPSHOT" + +application { + mainClass.set("com.aamdigital.aambackendservice.ApplicationKt") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-amqp") + implementation("org.springframework.boot:spring-boot-starter-cache") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-webflux") + + implementation("org.springframework.data:spring-data-r2dbc") + + implementation("org.springframework.security:spring-security-oauth2-resource-server") + implementation("org.springframework.security:spring-security-oauth2-jose") + + implementation("org.apache.commons:commons-lang3") + + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") + + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + + implementation("io.r2dbc:r2dbc-h2") + + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + + testImplementation("io.projectreactor:reactor-test") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") +} + +sentry { + // Generates a JVM (Java, Kotlin, etc.) source bundle and uploads your source code to Sentry. + // This enables source context, allowing you to see your source + // code as part of your stack traces in Sentry. + includeSourceContext = true + + org = "aam-digital" + projectName = "aam-backend-service" + authToken = System.getenv("SENTRY_AUTH_TOKEN") + version = System.getenv("APPLICATION_VERSION") +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs += "-Xjsr305=strict" + jvmTarget = "17" + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/application/aam-backend-service/detekt-config.yml b/application/aam-backend-service/detekt-config.yml new file mode 100644 index 0000000..fa59728 --- /dev/null +++ b/application/aam-backend-service/detekt-config.yml @@ -0,0 +1,805 @@ +build: + excludeCorrectable: false + +config: + validation: true + warningsAsErrors: false + checkExhaustiveness: false + # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' + excludes: '' + +processors: + active: true + exclude: + - 'DetektProgressListener' + # - 'KtFileCountProcessor' + # - 'PackageCountProcessor' + # - 'ClassCountProcessor' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ProjectComplexityProcessor' + # - 'ProjectCognitiveComplexityProcessor' + # - 'ProjectLLOCProcessor' + # - 'ProjectCLOCProcessor' + # - 'ProjectLOCProcessor' + # - 'ProjectSLOCProcessor' + # - 'LicenseHeaderLoaderExtension' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FindingsReport' + - 'FileBasedFindingsReport' + # - 'LiteFindingsReport' + +output-reports: + active: true + exclude: + # - 'TxtOutputReport' + # - 'XmlOutputReport' + # - 'HtmlOutputReport' + # - 'MdOutputReport' + # - 'sarif' + +comments: + active: true + AbsentOrWrongFileLicense: + active: false + licenseTemplateFile: 'license.template' + licenseTemplateIsRegex: false + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + DeprecatedBlockTag: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + KDocReferencesNonPublicProperty: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + OutdatedDocumentation: + active: false + matchTypeParameters: true + matchDeclarationsOrder: true + allowParamOnConstructorProperties: false + UndocumentedPublicClass: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + searchInProtectedClass: false + UndocumentedPublicFunction: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + searchProtectedFunction: false + UndocumentedPublicProperty: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + searchProtectedProperty: false + +complexity: + active: true + CognitiveComplexMethod: + active: false + allowedComplexity: 15 + ComplexCondition: + active: true + allowedConditions: 3 + ComplexInterface: + active: false + allowedDefinitions: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + ignoreOverloaded: false + CyclomaticComplexMethod: + active: true + allowedComplexity: 14 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + ignoreLocalFunctions: false + nestingFunctions: + - 'also' + - 'apply' + - 'forEach' + - 'isNotNull' + - 'ifNull' + - 'let' + - 'run' + - 'use' + - 'with' + LabeledExpression: + active: false + ignoredLabels: [ ] + LargeClass: + active: true + allowedLines: 600 + LongMethod: + active: true + allowedLines: 60 + LongParameterList: + active: true + allowedFunctionParameters: 5 + allowedConstructorParameters: 6 + ignoreDefaultParameters: false + ignoreDataClasses: true + ignoreAnnotatedParameter: [ ] + MethodOverloading: + active: false + allowedOverloads: 6 + NamedArguments: + active: false + allowedArguments: 3 + ignoreArgumentsMatchingNames: false + NestedBlockDepth: + active: true + allowedDepth: 4 + NestedScopeFunctions: + active: false + allowedDepth: 1 + functions: + - 'kotlin.apply' + - 'kotlin.run' + - 'kotlin.with' + - 'kotlin.let' + - 'kotlin.also' + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + allowedDuplications: 2 + ignoreAnnotation: true + allowedWithLengthLessThan: 5 + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + allowedFunctionsPerFile: 11 + allowedFunctionsPerClass: 11 + allowedFunctionsPerInterface: 11 + allowedFunctionsPerObject: 11 + allowedFunctionsPerEnum: 11 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + +coroutines: + active: true + GlobalCoroutineUsage: + active: false + InjectDispatcher: + active: true + dispatcherNames: + - 'IO' + - 'Default' + - 'Unconfined' + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunSwallowedCancellation: + active: false + SuspendFunWithCoroutineScopeReceiver: + active: false + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKotlinFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: + - 'equals' + - 'finalize' + - 'hashCode' + - 'toString' + InstanceOfCheckForException: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + NotImplementedDeclaration: + active: false + ObjectExtendsThrowable: + active: false + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + ignoreLabeled: false + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + exceptions: + - 'ArrayIndexOutOfBoundsException' + - 'Exception' + - 'IllegalArgumentException' + - 'IllegalMonitorStateException' + - 'IllegalStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'Exception' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: false + allowedPattern: '^(is|has|are)' + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [ ] + FunctionNameMaxLength: + active: false + maximumFunctionNameLength: 30 + FunctionNameMinLength: + active: false + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + functionPattern: '[a-z][a-zA-Z0-9]*' + excludeClassPattern: '$^' + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + InvalidPackageDeclaration: + active: true + rootPackage: '' + requireRootInDeclaration: false + LambdaParameterNaming: + active: false + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: true + mustBeFirst: true + multiplatformTargets: + - 'ios' + - 'android' + - 'js' + - 'jvm' + - 'native' + - 'iosArm64' + - 'iosX64' + - 'macosX64' + - 'mingwX64' + - 'linuxX64' + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: false + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: false + allowedOperations: 2 + ForEachOnRange: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + SpreadOperator: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + UnnecessaryPartOfBinaryExpression: + active: false + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + AvoidReferentialEquality: + active: true + forbiddenTypePatterns: + - 'kotlin.String' + CastNullableToNonNullableType: + active: false + ignorePlatformTypes: true + CastToNullableType: + active: false + CharArrayToStringCall: + active: false + Deprecation: + active: false + DontDowncastCollectionTypes: + active: false + DoubleMutabilityForCollection: + active: true + mutableTypes: + - 'kotlin.collections.MutableList' + - 'kotlin.collections.MutableMap' + - 'kotlin.collections.MutableSet' + - 'java.util.ArrayList' + - 'java.util.LinkedHashSet' + - 'java.util.HashSet' + - 'java.util.LinkedHashMap' + - 'java.util.HashMap' + ElseCaseInsteadOfExhaustiveWhen: + active: false + ignoredSubjectTypes: [ ] + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: false + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + restrictToConfig: true + returnValueAnnotations: + - 'CheckResult' + - '*.CheckResult' + - 'CheckReturnValue' + - '*.CheckReturnValue' + ignoreReturnValueAnnotations: + - 'CanIgnoreReturnValue' + - '*.CanIgnoreReturnValue' + returnValueTypes: + - 'kotlin.sequences.Sequence' + - 'kotlinx.coroutines.flow.*Flow' + - 'java.util.stream.*Stream' + ignoreFunctionCall: [ ] + ImplicitDefaultLocale: + active: true + ImplicitUnitReturnType: + active: false + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: false + excludes: [ '**/*.kts' ] + NullCheckOnMutableProperty: + active: false + NullableToStringCall: + active: false + PropertyUsedBeforeDeclaration: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnnamedParameterUse: + active: false + allowAdjacentDifferentTypeParams: true + allowSingleParamUse: true + UnnecessaryNotNullCheck: + active: false + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ] + UnsafeCast: + active: true + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + AbstractClassCanBeConcreteClass: + active: true + AbstractClassCanBeInterface: + active: true + AlsoCouldBeApply: + active: false + BracesOnIfStatements: + active: false + singleLine: 'never' + multiLine: 'always' + BracesOnWhenStatements: + active: false + singleLine: 'necessary' + multiLine: 'consistent' + CanBeNonNullable: + active: false + CascadingCallWrapping: + active: false + includeElvis: true + ClassOrdering: + active: false + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: + - 'to' + allowOperators: false + DataClassShouldBeImmutable: + active: false + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 3 + DoubleNegativeLambda: + active: false + negativeFunctions: + - reason: 'Use `takeIf` instead.' + value: 'takeUnless' + - reason: 'Use `all` instead.' + value: 'none' + negativeFunctionNameParts: + - 'not' + - 'non' + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: true + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenAnnotation: + active: false + annotations: + - reason: 'it is a java annotation. Use `Suppress` instead.' + value: 'java.lang.SuppressWarnings' + - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' + value: 'java.lang.Deprecated' + - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' + value: 'java.lang.annotation.Documented' + - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' + value: 'java.lang.annotation.Target' + - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' + value: 'java.lang.annotation.Retention' + - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' + value: 'java.lang.annotation.Repeatable' + - reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265' + value: 'java.lang.annotation.Inherited' + ForbiddenComment: + active: true + comments: + - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' + value: 'FIXME:' + - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' + value: 'STOPSHIP:' + - reason: 'Forbidden TODO todo marker in comment, please do the changes.' + value: 'TODO:' + allowedPatterns: '' + ForbiddenImport: + active: false + imports: [ ] + forbiddenPatterns: '' + ForbiddenMethodCall: + active: false + methods: + - reason: 'print does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.print' + - reason: 'println does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.println' + - reason: 'using `BigDecimal.(kotlin.Double)` can result in unexpected float point precision behavior. Use `BigDecimal.valueOf(kotlin.Double)` or `BigDecimal.(kotlin.String)` instead.' + value: 'java.math.BigDecimal.(kotlin.Double)' + ForbiddenSuppress: + active: false + rules: [ ] + ForbiddenVoid: + active: true + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: [ ] + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ] + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + MandatoryBracesLoops: + active: false + MaxChainedCallsOnSameLine: + active: false + maxChainedCalls: 5 + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + excludeRawStrings: true + MayBeConstant: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: false + MultilineRawStringIndentation: + active: false + indentSize: 4 + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + NullableBooleanCheck: + active: false + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: true + RangeUntilInsteadOfRangeTo: + active: false + RedundantConstructorKeyword: + active: false + RedundantExplicitType: + active: false + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 4 + excludedFunctions: + - 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: true + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingAfterPackageDeclaration: + active: false + StringShouldBeRawString: + active: false + maxEscapedCharacterCount: 2 + ignoredCharacters: [ ] + ThrowsCount: + active: true + max: 2 + excludeGuardClauses: false + TrailingWhitespace: + active: false + TrimMultilineRawString: + active: false + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + UnderscoresInNumericLiterals: + active: false + acceptableLength: 4 + allowNonStandardGrouping: false + UnnecessaryAnnotationUseSiteTarget: + active: false + UnnecessaryAny: + active: false + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: false + UnnecessaryBracesAroundTrailingLambda: + active: false + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + allowForUnclearPrecedence: false + UnusedImport: + active: false + UnusedParameter: + active: true + allowedNames: 'ignored|expected' + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: '' + UnusedPrivateProperty: + active: true + allowedNames: '_|ignored|expected|serialVersionUID' + UseAnyOrNoneInsteadOfFind: + active: true + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: false + allowVars: false + UseEmptyCounterpart: + active: false + UseIfEmptyOrIfBlank: + active: false + UseIfInsteadOfWhen: + active: false + ignoreWhenContainingVariableDeclaration: false + UseIsNullOrEmpty: + active: true + UseLet: + active: false + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UseSumOfInsteadOfFlatMapSize: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + ignoreLateinitVar: false + WildcardImport: + active: true + excludeImports: + - 'java.util.*' diff --git a/application/aam-backend-service/gradle/wrapper/gradle-wrapper.jar b/application/aam-backend-service/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d64cd4917707c1f8861d8cb53dd15194d4248596 GIT binary patch literal 43462 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 literal 0 HcmV?d00001 diff --git a/application/aam-backend-service/gradle/wrapper/gradle-wrapper.properties b/application/aam-backend-service/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/application/aam-backend-service/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/application/aam-backend-service/gradlew b/application/aam-backend-service/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/application/aam-backend-service/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed 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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +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 + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/application/aam-backend-service/gradlew.bat b/application/aam-backend-service/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/application/aam-backend-service/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem 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, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/application/aam-backend-service/settings.gradle.kts b/application/aam-backend-service/settings.gradle.kts new file mode 100644 index 0000000..af624fb --- /dev/null +++ b/application/aam-backend-service/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "aam-backend-service" diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/Application.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/Application.kt new file mode 100644 index 0000000..d2ecaca --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/Application.kt @@ -0,0 +1,15 @@ +package com.aamdigital.aambackendservice + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling + +@SpringBootApplication +@ConfigurationPropertiesScan +@EnableScheduling +class Application + +fun main(args: Array) { + runApplication(*args) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbClient.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbClient.kt new file mode 100644 index 0000000..1ef0ce2 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbClient.kt @@ -0,0 +1,189 @@ +package com.aamdigital.aambackendservice.couchdb.core + +import com.aamdigital.aambackendservice.couchdb.dto.CouchDbChangesResponse +import com.aamdigital.aambackendservice.couchdb.dto.DocSuccess +import com.aamdigital.aambackendservice.couchdb.dto.FindResponse +import com.aamdigital.aambackendservice.error.InternalServerException +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import org.slf4j.LoggerFactory +import org.springframework.core.ParameterizedTypeReference +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.util.MultiValueMap +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono +import kotlin.reflect.KClass + +class CouchDbClient( + private val webClient: WebClient, + private val objectMapper: ObjectMapper +) : CouchDbStorage { + + private val logger = LoggerFactory.getLogger(javaClass) + + companion object { + private const val CHANGES_URL = "/_changes" + private const val FIND_URL = "/_find" + } + + override fun allDatabases(): Mono> { + return webClient + .get() + .uri("/_all_dbs") + .accept(MediaType.APPLICATION_JSON) + .exchangeToMono { response -> + response.bodyToMono(object : ParameterizedTypeReference>() {}) + } + } + + override fun changes( + database: String, queryParams: MultiValueMap + ): Mono { + return webClient.get().uri { + it.path("/$database/$CHANGES_URL") + it.queryParams(queryParams) + it.build() + }.accept(MediaType.APPLICATION_JSON).exchangeToMono { response -> + response.bodyToMono(CouchDbChangesResponse::class.java).mapNotNull { + it + } + } + } + + override fun find( + database: String, body: Map, queryParams: MultiValueMap, kClass: KClass + ): Mono> { + return webClient.post().uri { + it.path("/$database/$FIND_URL") + it.queryParams(queryParams) + it.build() + }.contentType(MediaType.APPLICATION_JSON).body(BodyInserters.fromValue(body)).exchangeToMono { + it.bodyToMono(ObjectNode::class.java).map { objectNode -> + val data = + (objectMapper.convertValue(objectNode, Map::class.java)["docs"] as Iterable<*>).map { entry -> + objectMapper.convertValue(entry, kClass.java) + } + + FindResponse(docs = data) + } + } + } + + override fun headDatabaseDocument( + database: String, + documentId: String, + ): Mono { + return webClient + .head() + .uri { + it.path("/$database/$documentId") + it.build() + } + .accept(MediaType.APPLICATION_JSON).exchangeToMono { + if (it.statusCode().is2xxSuccessful) { + Mono.just(it.headers().asHttpHeaders()) + } else if (it.statusCode().is4xxClientError) { + Mono.just(HttpHeaders()) + } else { + throw InternalServerException() + } + } + } + + override fun getDatabaseDocument( + database: String, + documentId: String, + queryParams: MultiValueMap, + kClass: KClass, + ): Mono { + return webClient.get().uri { + it.path("/$database/$documentId") + it.queryParams(queryParams) + it.build() + }.accept(MediaType.APPLICATION_JSON).exchangeToMono { + handleResponse(it, kClass) + } + } + + override fun putDatabaseDocument( + database: String, + documentId: String, + body: Any, + ): Mono { + return headDatabaseDocument( + database = database, + documentId = documentId + ).flatMap { httpHeaders -> + val etag = httpHeaders.eTag?.replace("\"", "") + + webClient.put().uri { + it.path("/$database/$documentId") + it.build() + }.body(BodyInserters.fromValue(body)).headers { + if (etag.isNullOrBlank().not()) { + it.set("If-Match", etag) + } + }.accept(MediaType.APPLICATION_JSON).exchangeToMono { + handleResponse(it, DocSuccess::class) + } + } + } + + override fun getPreviousDocRev( + database: String, + documentId: String, + rev: String, + kClass: KClass, + ): Mono { + val allRevsInfoQueryParams = getEmptyQueryParams() + allRevsInfoQueryParams.set("revs_info", "true") + + return getDatabaseDocument( + database = database, + documentId = documentId, + queryParams = allRevsInfoQueryParams, + kClass = ObjectNode::class + ).flatMap { currentDoc -> + val revInfo = currentDoc.get("_revs_info") ?: return@flatMap Mono.empty() + + if (!revInfo.isArray) { + return@flatMap Mono.empty() + } + + val revIndex = revInfo.indexOfFirst { jsonNode -> jsonNode.get("rev").textValue().equals(rev) } + + if (revIndex == -1) { + return@flatMap Mono.empty() + } + + if (revIndex + 1 >= revInfo.size()) { + return@flatMap Mono.empty() + } + + val previousRef = revInfo.get(revIndex + 1).get("rev").textValue() + + val previousRevQueryParams = getEmptyQueryParams() + previousRevQueryParams.set("rev", previousRef) + + getDatabaseDocument( + database = database, + documentId = documentId, + queryParams = previousRevQueryParams, + kClass = ObjectNode::class + ).map { previousDoc -> + objectMapper.convertValue(previousDoc, kClass.java) + } + } + } + + private fun handleResponse( + response: ClientResponse, typeReference: KClass + ): Mono { + return response.bodyToMono(typeReference.java).mapNotNull { + it + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbHelper.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbHelper.kt new file mode 100644 index 0000000..e33aca0 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbHelper.kt @@ -0,0 +1,13 @@ +package com.aamdigital.aambackendservice.couchdb.core + +import org.springframework.util.LinkedMultiValueMap + +fun getQueryParamsAllDocs(key: String): LinkedMultiValueMap { + val queryParams = LinkedMultiValueMap() + queryParams.add("include_docs", "true") + queryParams.add("startkey", "\"$key:\"") + queryParams.add("endkey", "\"$key:\\ufff0\"") + return queryParams +} + +fun getEmptyQueryParams() = LinkedMultiValueMap() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbStorage.kt new file mode 100644 index 0000000..40f4bbb --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/core/CouchDbStorage.kt @@ -0,0 +1,46 @@ +package com.aamdigital.aambackendservice.couchdb.core + +import com.aamdigital.aambackendservice.couchdb.dto.CouchDbChangesResponse +import com.aamdigital.aambackendservice.couchdb.dto.DocSuccess +import com.aamdigital.aambackendservice.couchdb.dto.FindResponse +import org.springframework.http.HttpHeaders +import org.springframework.util.MultiValueMap +import reactor.core.publisher.Mono +import kotlin.reflect.KClass + +interface CouchDbStorage { + fun allDatabases(): Mono> + fun changes(database: String, queryParams: MultiValueMap): Mono + + fun find( + database: String, + body: Map, + queryParams: MultiValueMap, + kClass: KClass + ): Mono> + + fun headDatabaseDocument( + database: String, + documentId: String, + ): Mono + + fun getDatabaseDocument( + database: String, + documentId: String, + queryParams: MultiValueMap, + kClass: KClass, + ): Mono + + fun putDatabaseDocument( + database: String, + documentId: String, + body: Any + ): Mono + + fun getPreviousDocRev( + database: String, + documentId: String, + rev: String, + kClass: KClass, + ): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/di/CouchDbConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/di/CouchDbConfiguration.kt new file mode 100644 index 0000000..ff2cac1 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/di/CouchDbConfiguration.kt @@ -0,0 +1,42 @@ +package com.aamdigital.aambackendservice.couchdb.di + +import com.aamdigital.aambackendservice.couchdb.core.CouchDbClient +import com.aamdigital.aambackendservice.couchdb.core.CouchDbStorage +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient + +@Configuration +class CouchDbConfiguration { + + @Bean + fun defaultCouchDbStorage( + @Qualifier("couch-db-client") webClient: WebClient, + objectMapper: ObjectMapper, + ): CouchDbStorage = CouchDbClient(webClient, objectMapper) + + @Bean(name = ["couch-db-client"]) + fun couchDbWebClient(couchDbClientConfiguration: CouchDbClientConfiguration): WebClient { + val clientBuilder = + WebClient.builder().baseUrl(couchDbClientConfiguration.basePath) + .defaultHeaders { + it.setBasicAuth( + couchDbClientConfiguration.basicAuthUsername, + couchDbClientConfiguration.basicAuthPassword, + ) + } + return clientBuilder.clientConnector(ReactorClientHttpConnector(HttpClient.create())).build() + } +} + +@ConfigurationProperties("couch-db-client-configuration") +class CouchDbClientConfiguration( + val basePath: String, + val basicAuthUsername: String, + val basicAuthPassword: String, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/dto/CouchDbDto.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/dto/CouchDbDto.kt new file mode 100644 index 0000000..124b37f --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/couchdb/dto/CouchDbDto.kt @@ -0,0 +1,54 @@ +package com.aamdigital.aambackendservice.couchdb.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.node.ObjectNode + +data class CouchDbChange( + val rev: String, +) + +data class CouchDbRow( + val id: String, + val key: String, + val value: CouchDbChange, + val doc: T, +) + +data class DocSuccess( + val ok: Boolean, + val id: String, + val rev: String, +) + +data class FindResponse( + val docs: List +) + +/** + * A single result entry from a CouchDB changes feed, + * indicating one doc has changed. + * + * see https://docs.couchdb.org/en/stable/api/database/changes.html + */ +data class CouchDbChangeResult( + /** _id of a doc with changes */ + val id: String, + /** List of document’s leaves with single field rev. */ + val changes: List, + val seq: String, + val doc: ObjectNode?, + val deleted: Boolean? = false, +) + +/** + * Response from the CouchDB changes endpoint, listing database docs that have changed + * since the given last change (last_seq). + * + * see https://docs.couchdb.org/en/stable/api/database/changes.html + */ +data class CouchDbChangesResponse( + @JsonProperty("last_seq") + val lastSeq: String, + val results: List, + val pending: Int, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/crypto/core/CryptoService.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/crypto/core/CryptoService.kt new file mode 100644 index 0000000..b8faa32 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/crypto/core/CryptoService.kt @@ -0,0 +1,66 @@ +package com.aamdigital.aambackendservice.crypto.core + +import org.springframework.boot.context.properties.ConfigurationProperties +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.experimental.and + +@ConfigurationProperties("crypto-configuration") +data class CryptoConfig( + val secret: String +) + +data class EncryptedData(val iv: String, val data: String) + +class CryptoService( + private val config: CryptoConfig +) { + companion object { + val secureRandom = java.security.SecureRandom() + } + + fun decrypt(data: EncryptedData): String { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val secretKey = SecretKeySpec(getHash(config.secret), "AES") + val iv = IvParameterSpec(hexStringToByteArray(data.iv)) + + cipher.init(Cipher.DECRYPT_MODE, secretKey, iv) + val decryptedBytes = cipher.doFinal(hexStringToByteArray(data.data)) + + return String(decryptedBytes) + } + + fun encrypt(text: String): EncryptedData { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + val ivBytes = ByteArray(16).also { secureRandom.nextBytes(it) } + val secretKey = SecretKeySpec(getHash(config.secret), "AES") + + cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(ivBytes)) + val encryptedBytes = cipher.doFinal(text.toByteArray()) + + return EncryptedData( + iv = byteArrayToHexString(ivBytes), + data = byteArrayToHexString(encryptedBytes) + ) + } + + private fun getHash(text: String): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(text.toByteArray()) + } + + private fun hexStringToByteArray(s: String): ByteArray { + val len = s.length + val data = ByteArray(len / 2) + for (i in 0 until len step 2) { + data[i / 2] = ((Character.digit(s[i], 16) shl 4) + Character.digit(s[i + 1], 16)).toByte() + } + return data + } + + private fun byteArrayToHexString(array: ByteArray): String { + return array.joinToString("") { byte -> "%02x".format(byte and 0xFF.toByte()) } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/crypto/di/CryptoConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/crypto/di/CryptoConfiguration.kt new file mode 100644 index 0000000..63082e0 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/crypto/di/CryptoConfiguration.kt @@ -0,0 +1,16 @@ +package com.aamdigital.aambackendservice.crypto.di + +import com.aamdigital.aambackendservice.crypto.core.CryptoConfig +import com.aamdigital.aambackendservice.crypto.core.CryptoService +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class CryptoConfiguration { + + @Bean + fun defaultCryptoService( + config: CryptoConfig + ): CryptoService = + CryptoService(config) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/domain/DomainReference.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/domain/DomainReference.kt new file mode 100644 index 0000000..da4aef0 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/domain/DomainReference.kt @@ -0,0 +1,18 @@ +package com.aamdigital.aambackendservice.domain + +/** + * Representation of a reference to another Domain Object. + * Used, when just the Identifier is needed, not the hole object. + * + * @example You want to trigger a calculation for new Report + * and just got the ReportId from your controller. You just pass a DomainReference to that Report: + * + * triggerCalculation(reportId: DomainReference): Unit {} + * + * const reportId = "r-1"; + * triggerCalculation(DomainReference(reportId)); + * + */ +data class DomainReference( + val id: String +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/domain/EntityType.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/domain/EntityType.kt new file mode 100644 index 0000000..eb75480 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/domain/EntityType.kt @@ -0,0 +1,16 @@ +package com.aamdigital.aambackendservice.domain + +data class EntityAttribute( + val name: String, + val type: String, +) + +data class EntityType( + val label: String, + val attributes: List, +) + +data class EntityConfig( + val version: String, + val entities: List, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/error/AamException.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/error/AamException.kt new file mode 100644 index 0000000..7cbbf25 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/error/AamException.kt @@ -0,0 +1,48 @@ +package com.aamdigital.aambackendservice.error + +sealed class AamException( + message: String, + cause: Throwable? = null, + val code: String = DEFAULT_ERROR_CODE +) : Exception(message, cause) { + companion object { + private const val DEFAULT_ERROR_CODE = "AAM-GENERAL" + } +} + +class InternalServerException( + message: String = "", + cause: Throwable? = null, + code: String = "INTERNAL_SERVER_ERROR" +) : AamException(message, cause, code) + + +class ExternalSystemException( + message: String = "", + cause: Throwable? = null, + code: String = "EXTERNAL_SYSTEM_ERROR" +) : AamException(message, cause, code) + +class InvalidArgumentException( + message: String = "", + cause: Throwable? = null, + code: String = "BAD_REQUEST" +) : AamException(message, cause, code) + +class UnauthorizedAccessException( + message: String = "", + cause: Throwable? = null, + code: String = "UNAUTHORIZED" +) : AamException(message, cause, code) + +class ForbiddenAccessException( + message: String = "", + cause: Throwable? = null, + code: String = "FORBIDDEN" +) : AamException(message, cause, code) + +class NotFoundException( + message: String = "", + cause: Throwable? = null, + code: String = "NOT_FOUND" +) : AamException(message, cause, code) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/DefaultQueueMessageParser.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/DefaultQueueMessageParser.kt new file mode 100644 index 0000000..c804e46 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/DefaultQueueMessageParser.kt @@ -0,0 +1,83 @@ +package com.aamdigital.aambackendservice.queue.core + +import com.aamdigital.aambackendservice.error.InvalidArgumentException +import com.fasterxml.jackson.core.JacksonException +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import org.slf4j.LoggerFactory +import kotlin.reflect.KClass + +class DefaultQueueMessageParser( + private val objectMapper: ObjectMapper, +) : QueueMessageParser { + + private val logger = LoggerFactory.getLogger(javaClass) + + companion object { + private const val TYPE_FIELD = "type" + private const val PAYLOAD_FIELD = "payload" + } + + private fun getJsonNode(body: ByteArray): JsonNode { + return try { + objectMapper.readTree(body) + } catch (ex: JacksonException) { + throw InvalidArgumentException( + message = "Could not parse message.", + code = "INVALID_JSON", + cause = ex, + ) + } + } + + @Throws(InvalidArgumentException::class) + override fun getType(body: ByteArray): String { + val jsonNode = getJsonNode(body) + + if (!jsonNode.has(TYPE_FIELD)) { + throw InvalidArgumentException( + message = "Could not extract type from message.", + code = "MISSING_TYPE_FIELD", + ) + } + + return jsonNode.get(TYPE_FIELD).textValue() + } + + @Throws(InvalidArgumentException::class) + override fun getTypeKClass(body: ByteArray): KClass<*> { + val typeString = getType(body) + + try { + return Class.forName(typeString).kotlin + } catch (ex: ClassNotFoundException) { + logger.debug("INVALID_CLASS_NAME_DEBUG_EX", ex) + throw InvalidArgumentException( + message = "Could not find Class for this type.", + code = "INVALID_CLASS_NAME", + ) + } + } + + @Throws(InvalidArgumentException::class) + override fun getPayload(body: ByteArray, kClass: KClass): T { + val jsonNode = getJsonNode(body) + + if (!jsonNode.has(PAYLOAD_FIELD)) { + throw InvalidArgumentException( + message = "Could not extract payload from message.", + code = "MISSING_PAYLOAD_FIELD", + ) + } + + return try { + objectMapper.treeToValue(jsonNode.get(PAYLOAD_FIELD), kClass.java) + } catch (ex: JacksonException) { + throw InvalidArgumentException( + message = "Could not parse payload object from message.", + code = "INVALID_PAYLOAD", + cause = ex, + ) + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/QueueMessage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/QueueMessage.kt new file mode 100644 index 0000000..b3dc519 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/QueueMessage.kt @@ -0,0 +1,12 @@ +package com.aamdigital.aambackendservice.queue.core + +import java.util.* + +data class QueueMessage( + val id: UUID, + val type: String, + val payload: Any, + val createdAt: String, + val spanId: String? = null, + val traceId: String? = null, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/QueueMessageParser.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/QueueMessageParser.kt new file mode 100644 index 0000000..3d0087d --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/core/QueueMessageParser.kt @@ -0,0 +1,9 @@ +package com.aamdigital.aambackendservice.queue.core + +import kotlin.reflect.KClass + +interface QueueMessageParser { + fun getType(body: ByteArray): String + fun getTypeKClass(body: ByteArray): KClass<*> + fun getPayload(body: ByteArray, kClass: KClass): T +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/di/QueueConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/di/QueueConfiguration.kt new file mode 100644 index 0000000..3277d47 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/queue/di/QueueConfiguration.kt @@ -0,0 +1,20 @@ +package com.aamdigital.aambackendservice.queue.di + +import com.aamdigital.aambackendservice.queue.core.DefaultQueueMessageParser +import com.aamdigital.aambackendservice.queue.core.QueueMessageParser +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter +import org.springframework.amqp.support.converter.MessageConverter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class QueueConfiguration { + @Bean + fun defaultQueueMessageParser(objectMapper: ObjectMapper): QueueMessageParser = DefaultQueueMessageParser( + objectMapper = objectMapper + ) + + @Bean + fun defaultMessageConverter(): MessageConverter = Jackson2JsonMessageConverter() +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/ChangeEventPublisher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/ChangeEventPublisher.kt new file mode 100644 index 0000000..66e6fd6 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/ChangeEventPublisher.kt @@ -0,0 +1,10 @@ +package com.aamdigital.aambackendservice.reporting.changes.core + +import com.aamdigital.aambackendservice.queue.core.QueueMessage +import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent +import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent + +interface ChangeEventPublisher { + fun publish(channel: String, event: DatabaseChangeEvent): QueueMessage + fun publish(exchange: String, event: DocumentChangeEvent): QueueMessage +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CouchDbDatabaseChangeDetection.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CouchDbDatabaseChangeDetection.kt new file mode 100644 index 0000000..c270aa9 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CouchDbDatabaseChangeDetection.kt @@ -0,0 +1,94 @@ +package com.aamdigital.aambackendservice.reporting.changes.core + +import com.aamdigital.aambackendservice.couchdb.core.CouchDbStorage +import com.aamdigital.aambackendservice.couchdb.core.getEmptyQueryParams +import com.aamdigital.aambackendservice.reporting.changes.di.ChangesQueueConfiguration.Companion.DB_CHANGES_QUEUE +import com.aamdigital.aambackendservice.reporting.changes.repository.SyncEntry +import com.aamdigital.aambackendservice.reporting.changes.repository.SyncRepository +import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent +import org.slf4j.LoggerFactory +import reactor.core.publisher.Mono +import java.util.* + +class CouchDbDatabaseChangeDetection( + private val couchDbStorage: CouchDbStorage, + private val documentChangeEventPublisher: ChangeEventPublisher, + private val syncRepository: SyncRepository, +) : DatabaseChangeDetection { + private val logger = LoggerFactory.getLogger(javaClass) + + companion object { + private val LATEST_REFS: MutableMap = Collections.synchronizedMap(hashMapOf()) + private const val CHANGES_LIMIT: Int = 100 + } + + /** + * Will reach out to CouchDb and convert _changes to Domain.DocumentChangeEvent's + */ + override fun checkForChanges(): Mono { + logger.trace("[CouchDatabaseChangeDetection] start couchdb change detection...") + return couchDbStorage.allDatabases().flatMap { databases -> + val requests = databases.filter { !it.startsWith("_") }.map { database -> + fetchChangesForDatabase(database) + } + Mono.zip(requests) { + it.map { } + } + }.map { + logger.trace("[CouchDatabaseChangeDetection] ...completed couchdb change detection.") + } + } + + private fun fetchChangesForDatabase(database: String): Mono { + logger.trace("[CouchDatabaseChangeDetection] check changes for database \"{}\"...", database) + + return syncRepository.findByDatabase(database).defaultIfEmpty(SyncEntry(database = database, latestRef = "")) + .flatMap { + LATEST_REFS[database] = it.latestRef + + val queryParams = getEmptyQueryParams() + + if (LATEST_REFS.containsKey(database) && LATEST_REFS.getValue(database).isNotEmpty()) { + queryParams.set("last-event-id", LATEST_REFS.getValue(database)) + } + + queryParams.set("limit", CHANGES_LIMIT.toString()) + queryParams.set("include_docs", "true") + + couchDbStorage.changes( + database = database, queryParams = queryParams + ) + } + .map { changes -> + changes.results.forEachIndexed { index, couchDbChangeResult -> + logger.trace("$database $index: {}", couchDbChangeResult.toString()) + + val rev = couchDbChangeResult.doc?.get("_rev")?.textValue() + + if (!couchDbChangeResult.id.startsWith("_design")) { + documentChangeEventPublisher.publish( + channel = DB_CHANGES_QUEUE, + DatabaseChangeEvent( + documentId = couchDbChangeResult.id, + database = database, + rev = rev, + deleted = couchDbChangeResult.deleted == true + ) + ) + } + + LATEST_REFS[database] = couchDbChangeResult.seq + } + } + .flatMap { + syncRepository.findByDatabase(database).defaultIfEmpty(SyncEntry(database = database, latestRef = "")) + } + .flatMap { + it.latestRef = LATEST_REFS[database].orEmpty() + syncRepository.save(it) + } + .map { + logger.trace("[CouchDatabaseChangeDetection] ...completed changes check for database \"{}\".", database) + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CreateDocumentChangeUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CreateDocumentChangeUseCase.kt new file mode 100644 index 0000000..2bc7d00 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CreateDocumentChangeUseCase.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.reporting.changes.core + +import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent +import reactor.core.publisher.Mono + +interface CreateDocumentChangeUseCase { + fun createEvent(event: DatabaseChangeEvent): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeDetection.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeDetection.kt new file mode 100644 index 0000000..ef8ee01 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeDetection.kt @@ -0,0 +1,7 @@ +package com.aamdigital.aambackendservice.reporting.changes.core + +import reactor.core.publisher.Mono + +interface DatabaseChangeDetection { + fun checkForChanges(): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeEventConsumer.kt new file mode 100644 index 0000000..37a163f --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeEventConsumer.kt @@ -0,0 +1,9 @@ +package com.aamdigital.aambackendservice.reporting.changes.core + +import com.rabbitmq.client.Channel +import org.springframework.amqp.core.Message +import reactor.core.publisher.Mono + +interface DatabaseChangeEventConsumer { + fun consume(rawMessage: String, message: Message, channel: Channel): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DefaultCreateDocumentChangeUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DefaultCreateDocumentChangeUseCase.kt new file mode 100644 index 0000000..56aa469 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DefaultCreateDocumentChangeUseCase.kt @@ -0,0 +1,76 @@ +package com.aamdigital.aambackendservice.reporting.changes.core + +import com.aamdigital.aambackendservice.couchdb.core.CouchDbStorage +import com.aamdigital.aambackendservice.couchdb.core.getEmptyQueryParams +import com.aamdigital.aambackendservice.reporting.changes.di.ChangesQueueConfiguration.Companion.DOCUMENT_CHANGES_EXCHANGE +import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent +import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import org.slf4j.LoggerFactory +import reactor.core.publisher.Mono + +/** + * Use case is called if a change on any database document is detected. + */ +class DefaultCreateDocumentChangeUseCase( + private val couchDbStorage: CouchDbStorage, + private val objectMapper: ObjectMapper, + private val documentChangeEventPublisher: ChangeEventPublisher, +) : CreateDocumentChangeUseCase { + val logger = LoggerFactory.getLogger(javaClass) + + override fun createEvent(event: DatabaseChangeEvent): Mono { + val queryParams = getEmptyQueryParams() + queryParams.set("rev", event.rev) + + return couchDbStorage.getDatabaseDocument( + database = event.database, + documentId = event.documentId, + queryParams = queryParams, + kClass = ObjectNode::class + ).zipWith( + if (event.rev.isNullOrBlank()) { + return Mono.empty() + } else { + couchDbStorage.getPreviousDocRev( + database = event.database, + documentId = event.documentId, + rev = event.rev, + kClass = ObjectNode::class + ).defaultIfEmpty( + objectMapper.createObjectNode() + ) + } + ).map { + val currentDoc = it.t1 + val previousDoc = it.t2 + + if (currentDoc.has("_deleted") + && currentDoc.get("_deleted").isBoolean + && currentDoc.get("_deleted").booleanValue() + ) { + DocumentChangeEvent( + database = event.database, + documentId = event.documentId, + rev = event.rev, + currentVersion = emptyMap(), + previousVersion = emptyMap(), + deleted = event.deleted + ) + } + + DocumentChangeEvent( + database = event.database, + documentId = event.documentId, + rev = event.rev, + currentVersion = objectMapper.convertValue(currentDoc, Map::class.java), + previousVersion = objectMapper.convertValue(previousDoc, Map::class.java), + deleted = event.deleted + ) + }.map { + logger.debug("[{}]: send event: {}", DOCUMENT_CHANGES_EXCHANGE, it) + documentChangeEventPublisher.publish(DOCUMENT_CHANGES_EXCHANGE, it) + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/NoopDatabaseChangeDetection.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/NoopDatabaseChangeDetection.kt new file mode 100644 index 0000000..c6caae1 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/NoopDatabaseChangeDetection.kt @@ -0,0 +1,16 @@ +package com.aamdigital.aambackendservice.reporting.changes.core + +import org.slf4j.LoggerFactory +import reactor.core.publisher.Mono + +class NoopDatabaseChangeDetection : DatabaseChangeDetection { + private val logger = LoggerFactory.getLogger(javaClass) + + override fun checkForChanges(): Mono { + logger.trace("[NoopDatabaseChangeDetection] start couchdb change detection...") + return Mono.just(Unit) + .map { + logger.trace("[NoopDatabaseChangeDetection] ...completed couchdb change detection.") + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesConfiguration.kt new file mode 100644 index 0000000..5815502 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesConfiguration.kt @@ -0,0 +1,54 @@ +package com.aamdigital.aambackendservice.reporting.changes.di + +import com.aamdigital.aambackendservice.couchdb.core.CouchDbStorage +import com.aamdigital.aambackendservice.reporting.changes.core.ChangeEventPublisher +import com.aamdigital.aambackendservice.reporting.changes.core.CouchDbDatabaseChangeDetection +import com.aamdigital.aambackendservice.reporting.changes.core.CreateDocumentChangeUseCase +import com.aamdigital.aambackendservice.reporting.changes.core.DatabaseChangeDetection +import com.aamdigital.aambackendservice.reporting.changes.core.DefaultCreateDocumentChangeUseCase +import com.aamdigital.aambackendservice.reporting.changes.core.NoopDatabaseChangeDetection +import com.aamdigital.aambackendservice.reporting.changes.repository.SyncRepository +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ChangesConfiguration { + + @Bean + @ConditionalOnProperty( + prefix = "database-change-detection", + name = ["enabled"], + havingValue = "false" + ) + fun noopDatabaseChangeDetection(): DatabaseChangeDetection = NoopDatabaseChangeDetection() + + @Bean + @ConditionalOnProperty( + prefix = "database-change-detection", + name = ["enabled"], + matchIfMissing = true + ) + fun couchDatabaseChangeDetection( + couchDbStorage: CouchDbStorage, + changeEventPublisher: ChangeEventPublisher, + syncRepository: SyncRepository, + ): DatabaseChangeDetection = CouchDbDatabaseChangeDetection( + couchDbStorage, + changeEventPublisher, + syncRepository + ) + + @Bean + fun defaultAnalyseDocumentChangeUseCase( + couchDbStorage: CouchDbStorage, + objectMapper: ObjectMapper, + changeEventPublisher: ChangeEventPublisher + ): CreateDocumentChangeUseCase = + DefaultCreateDocumentChangeUseCase( + couchDbStorage = couchDbStorage, + objectMapper = objectMapper, + documentChangeEventPublisher = changeEventPublisher, + ) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesQueueConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesQueueConfiguration.kt new file mode 100644 index 0000000..9fe2e8b --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesQueueConfiguration.kt @@ -0,0 +1,70 @@ +package com.aamdigital.aambackendservice.reporting.changes.di + +import com.aamdigital.aambackendservice.queue.core.QueueMessageParser +import com.aamdigital.aambackendservice.reporting.changes.core.ChangeEventPublisher +import com.aamdigital.aambackendservice.reporting.changes.core.CreateDocumentChangeUseCase +import com.aamdigital.aambackendservice.reporting.changes.core.DatabaseChangeEventConsumer +import com.aamdigital.aambackendservice.reporting.changes.queue.DefaultChangeEventPublisher +import com.aamdigital.aambackendservice.reporting.changes.queue.DefaultDatabaseChangeEventConsumer +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.amqp.core.Binding +import org.springframework.amqp.core.BindingBuilder +import org.springframework.amqp.core.FanoutExchange +import org.springframework.amqp.core.Queue +import org.springframework.amqp.core.QueueBuilder +import org.springframework.amqp.rabbit.core.RabbitTemplate +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ChangesQueueConfiguration { + companion object { + const val DB_CHANGES_QUEUE = "db.changes" + const val DB_CHANGES_DEAD_LETTER_QUEUE = "db.changes.deadLetter" + const val DB_CHANGES_DEAD_LETTER_EXCHANGE = "$DB_CHANGES_QUEUE.dlx" + const val DOCUMENT_CHANGES_EXCHANGE = "document.changes" + } + + @Bean + fun dbChangesQueue(): Queue = QueueBuilder + .durable(DB_CHANGES_QUEUE) + .deadLetterExchange(DB_CHANGES_DEAD_LETTER_EXCHANGE) + .build() + + @Bean("db-changes-dead-letter-queue") + fun dbChangesDeadLetterQueue(): Queue = QueueBuilder + .durable(DB_CHANGES_DEAD_LETTER_QUEUE) + .build() + + @Bean("db-changes-dead-letter-exchange") + fun dbChangesDeadLetterExchange(): FanoutExchange = FanoutExchange(DB_CHANGES_DEAD_LETTER_EXCHANGE) + + @Bean + fun deadLetterBinding( + @Qualifier("db-changes-dead-letter-queue") dbChangesDeadLetterQueue: Queue, + @Qualifier("db-changes-dead-letter-exchange") dbChangesDeadLetterExchange: FanoutExchange, + ): Binding = + BindingBuilder.bind(dbChangesDeadLetterQueue).to(dbChangesDeadLetterExchange) + + @Bean("document-changes-exchange") + fun documentChangesExchange(): FanoutExchange = FanoutExchange(DOCUMENT_CHANGES_EXCHANGE) + + @Bean + fun defaultChangeEventPublisher( + objectMapper: ObjectMapper, + rabbitTemplate: RabbitTemplate, + ): ChangeEventPublisher = DefaultChangeEventPublisher( + objectMapper = objectMapper, + rabbitTemplate = rabbitTemplate, + ) + + @Bean + fun defaultDocumentChangeEventProcessor( + messageParser: QueueMessageParser, + useCase: CreateDocumentChangeUseCase, + ): DatabaseChangeEventConsumer = DefaultDatabaseChangeEventConsumer( + messageParser = messageParser, + useCase = useCase, + ) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/RepositoryConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/RepositoryConfiguration.kt new file mode 100644 index 0000000..183e248 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/RepositoryConfiguration.kt @@ -0,0 +1,29 @@ +package com.aamdigital.aambackendservice.reporting.changes.di + +import io.r2dbc.spi.ConnectionFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.io.ClassPathResource +import org.springframework.r2dbc.connection.init.CompositeDatabasePopulator +import org.springframework.r2dbc.connection.init.ConnectionFactoryInitializer +import org.springframework.r2dbc.connection.init.ResourceDatabasePopulator + +@Configuration +class RepositoryConfiguration { + + @Bean + fun connectionFactoryInitializer(connectionFactory: ConnectionFactory): ConnectionFactoryInitializer { + val initializer = ConnectionFactoryInitializer() + initializer.setConnectionFactory(connectionFactory) + + val populator = CompositeDatabasePopulator() + populator.addPopulators( + ResourceDatabasePopulator( + ClassPathResource("sql/embedded_h2_database_init_script.sql"), + ) + ) + initializer.setDatabasePopulator(populator) + + return initializer + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/jobs/CouchDbChangeDetectionJob.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/jobs/CouchDbChangeDetectionJob.kt new file mode 100644 index 0000000..de65493 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/jobs/CouchDbChangeDetectionJob.kt @@ -0,0 +1,47 @@ +package com.aamdigital.aambackendservice.reporting.changes.jobs + +import com.aamdigital.aambackendservice.reporting.changes.core.DatabaseChangeDetection +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.Scheduled + +@Configuration +class CouchDbChangeDetectionJob( + private val databaseChangeDetection: DatabaseChangeDetection +) { + + private val logger = LoggerFactory.getLogger(javaClass) + + companion object { + private var ERROR_COUNTER: Int = 0 + private var MAX_ERROR_COUNT: Int = 5 + } + + @Scheduled(fixedDelay = 15000) + fun checkForCouchDbChanges() { + if (ERROR_COUNTER >= MAX_ERROR_COUNT) { + return + } + logger.trace("[CouchDbChangeDetectionJob] Starting job...") + try { + databaseChangeDetection.checkForChanges() + .doOnError { + logger.error( + "[CouchDbChangeDetectionJob] An error occurred (count: $ERROR_COUNTER): {}", + it.localizedMessage + ) + logger.debug("[CouchDbChangeDetectionJob] Debug information", it) + ERROR_COUNTER += 1 + } + .subscribe { + logger.trace("[CouchDbChangeDetectionJob]: ...job completed.") + } + } catch (ex: Exception) { + logger.error( + "[CouchDbChangeDetectionJob] An error occurred {}", + ex.localizedMessage + ) + logger.debug("[CouchDbChangeDetectionJob] Debug information", ex) + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultChangeEventPublisher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultChangeEventPublisher.kt new file mode 100644 index 0000000..7e3d642 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultChangeEventPublisher.kt @@ -0,0 +1,89 @@ +package com.aamdigital.aambackendservice.reporting.changes.queue + +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.error.InternalServerException +import com.aamdigital.aambackendservice.queue.core.QueueMessage +import com.aamdigital.aambackendservice.reporting.changes.core.ChangeEventPublisher +import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent +import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.amqp.AmqpException +import org.springframework.amqp.rabbit.core.RabbitTemplate +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.* + +class DefaultChangeEventPublisher( + private val objectMapper: ObjectMapper, + private val rabbitTemplate: RabbitTemplate, +) : ChangeEventPublisher { + + private val logger = LoggerFactory.getLogger(javaClass) + + @Throws(AamException::class) + override fun publish(channel: String, event: DatabaseChangeEvent): QueueMessage { + val message = QueueMessage( + id = UUID.randomUUID(), + type = DatabaseChangeEvent::class.java.canonicalName, + payload = event, + createdAt = Instant.now() + .atOffset(ZoneOffset.UTC) + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + ) + + try { + rabbitTemplate.convertAndSend( + channel, + objectMapper.writeValueAsString(message) + ) + } catch (ex: AmqpException) { + throw InternalServerException( + message = "Could not publish DatabaseChangeEvent: $event", + code = "EVENT_PUBLISH_ERROR", + cause = ex + ) + } + + logger.trace( + "[DefaultDatabaseChangeEventPublisher]: publish message to channel '{}' Payload: {}", + channel, + jacksonObjectMapper().writeValueAsString(message) + ) + return message + } + + @Throws(AamException::class) + override fun publish(exchange: String, event: DocumentChangeEvent): QueueMessage { + val message = QueueMessage( + id = UUID.randomUUID(), + type = DocumentChangeEvent::class.java.canonicalName, + payload = event, + createdAt = Instant.now() + .atOffset(ZoneOffset.UTC) + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + ) + try { + rabbitTemplate.convertAndSend( + exchange, + "", + objectMapper.writeValueAsString(message) + ) + } catch (ex: AmqpException) { + throw InternalServerException( + message = "Could not publish DocumentChangeEvent: $event", + code = "EVENT_PUBLISH_ERROR", + cause = ex + ) + } + + logger.trace( + "[DefaultDocumentChangeEventPublisher]: publish message to channel '{}' Payload: {}", + exchange, + jacksonObjectMapper().writeValueAsString(message) + ) + return message + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultDatabaseChangeEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultDatabaseChangeEventConsumer.kt new file mode 100644 index 0000000..7406686 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultDatabaseChangeEventConsumer.kt @@ -0,0 +1,60 @@ +package com.aamdigital.aambackendservice.reporting.changes.queue + +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.queue.core.QueueMessageParser +import com.aamdigital.aambackendservice.reporting.changes.core.CreateDocumentChangeUseCase +import com.aamdigital.aambackendservice.reporting.changes.core.DatabaseChangeEventConsumer +import com.aamdigital.aambackendservice.reporting.changes.di.ChangesQueueConfiguration.Companion.DB_CHANGES_QUEUE +import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent +import com.rabbitmq.client.Channel +import org.slf4j.LoggerFactory +import org.springframework.amqp.AmqpRejectAndDontRequeueException +import org.springframework.amqp.core.Message +import org.springframework.amqp.rabbit.annotation.RabbitListener +import reactor.core.publisher.Mono + +class DefaultDatabaseChangeEventConsumer( + private val messageParser: QueueMessageParser, + private val useCase: CreateDocumentChangeUseCase, +) : DatabaseChangeEventConsumer { + + private val logger = LoggerFactory.getLogger(javaClass) + + @RabbitListener( + queues = [DB_CHANGES_QUEUE], + ackMode = "MANUAL" + ) + override fun consume(rawMessage: String, message: Message, channel: Channel): Mono { + val type = try { + messageParser.getTypeKClass(rawMessage.toByteArray()) + } catch (ex: AamException) { + return Mono.error { throw AmqpRejectAndDontRequeueException("[${ex.code}] ${ex.localizedMessage}", ex) } + } + + when (type.qualifiedName) { + DatabaseChangeEvent::class.qualifiedName -> { + val payload = messageParser.getPayload( + body = rawMessage.toByteArray(), + kClass = DatabaseChangeEvent::class + ) + + logger.debug("Payload parsed: {}", payload) + + return useCase.createEvent(payload).flatMap { + Mono.empty() + } + } + + else -> { + logger.warn( + "[DefaultDocumentChangeEventProcessor] Could not find any use case for this EventType: {}", + type.qualifiedName, + ) + + throw AmqpRejectAndDontRequeueException( + "[NO_USECASE_CONFIGURED] Could not found matching use case for: ${type.qualifiedName}", + ) + } + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt new file mode 100644 index 0000000..457ec0e --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt @@ -0,0 +1,16 @@ +package com.aamdigital.aambackendservice.reporting.changes.repository + +import org.springframework.data.annotation.Id +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import reactor.core.publisher.Mono + +data class SyncEntry( + @Id + var id: Long = 0, + var database: String, + var latestRef: String, +) + +interface SyncRepository : ReactiveCrudRepository { + fun findByDatabase(database: String): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/Report.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/Report.kt new file mode 100644 index 0000000..7c13522 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/Report.kt @@ -0,0 +1,9 @@ +package com.aamdigital.aambackendservice.reporting.domain + +data class Report( + val id: String, + val name: String, + val mode: String?, + val schema: ReportSchema?, + val query: String, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/ReportCalculation.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/ReportCalculation.kt new file mode 100644 index 0000000..b24b959 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/ReportCalculation.kt @@ -0,0 +1,57 @@ +package com.aamdigital.aambackendservice.reporting.domain + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo + +@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes( + JsonSubTypes.Type(value = ReportCalculationOutcome.Success::class), + JsonSubTypes.Type(value = ReportCalculationOutcome.Failure::class), +) +sealed class ReportCalculationOutcome { + data class Success( + @JsonProperty("result_hash") + val resultHash: String, + ) : ReportCalculationOutcome() + + data class Failure( + val errorCode: String, + val errorMessage: String, + ) : ReportCalculationOutcome() +} + +data class ReportCalculation( + val id: String, + val report: DomainReference, + var status: ReportCalculationStatus, + var startDate: String? = null, + var endDate: String? = null, + var outcome: ReportCalculationOutcome? = null, +) { + fun setStatus(status: ReportCalculationStatus): ReportCalculation { + this.status = status + return this + } + + fun setStartDate(startDate: String?): ReportCalculation { + this.startDate = startDate + return this + } + + fun setEndDate(endDate: String?): ReportCalculation { + this.endDate = endDate + return this + } + + fun setOutcome(outcome: ReportCalculationOutcome?): ReportCalculation { + this.outcome = outcome + return this + } +} + + +enum class ReportCalculationStatus { + PENDING, RUNNING, FINISHED_SUCCESS, FINISHED_ERROR +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/ReportData.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/ReportData.kt new file mode 100644 index 0000000..1a59c91 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/ReportData.kt @@ -0,0 +1,21 @@ +package com.aamdigital.aambackendservice.reporting.domain + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.fasterxml.jackson.databind.ObjectMapper +import java.security.MessageDigest + +data class ReportData( + val id: String, + val report: DomainReference, + val calculation: DomainReference, + var data: List<*>, +) { + @OptIn(ExperimentalStdlibApi::class) + fun getDataHash(): String { + val mapper = ObjectMapper() + val md = MessageDigest.getInstance("SHA-256") + val input = mapper.writeValueAsString(data).toByteArray() + val bytes = md.digest(input) + return bytes.toHexString() + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/ReportSchema.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/ReportSchema.kt new file mode 100644 index 0000000..34e8d21 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/ReportSchema.kt @@ -0,0 +1,5 @@ +package com.aamdigital.aambackendservice.reporting.domain + +data class ReportSchema( + val fields: List +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DatabaseChangeEvent.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DatabaseChangeEvent.kt new file mode 100644 index 0000000..b1090ea --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DatabaseChangeEvent.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.reporting.domain.event + +data class DatabaseChangeEvent( + val database: String, + val documentId: String, + val rev: String?, + val deleted: Boolean, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DocumentChangeEvent.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DocumentChangeEvent.kt new file mode 100644 index 0000000..d74201b --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DocumentChangeEvent.kt @@ -0,0 +1,10 @@ +package com.aamdigital.aambackendservice.reporting.domain.event + +data class DocumentChangeEvent( + val database: String, + val documentId: String, + val rev: String, + val currentVersion: Map<*, *>, + val previousVersion: Map<*, *>, + val deleted: Boolean +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/NotificationEvent.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/NotificationEvent.kt new file mode 100644 index 0000000..4a10fa9 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/NotificationEvent.kt @@ -0,0 +1,7 @@ +package com.aamdigital.aambackendservice.reporting.domain.event + +data class NotificationEvent( + val webhookId: String, + val reportId: String, + val calculationId: String, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/controller/WebhookController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/controller/WebhookController.kt new file mode 100644 index 0000000..c2552a8 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/controller/WebhookController.kt @@ -0,0 +1,136 @@ +package com.aamdigital.aambackendservice.reporting.notification.controller + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.ForbiddenAccessException +import com.aamdigital.aambackendservice.reporting.notification.core.AddWebhookSubscriptionUseCase +import com.aamdigital.aambackendservice.reporting.notification.core.CreateWebhookRequest +import com.aamdigital.aambackendservice.reporting.notification.core.NotificationStorage +import com.aamdigital.aambackendservice.reporting.notification.dto.Webhook +import com.aamdigital.aambackendservice.reporting.notification.dto.WebhookAuthenticationType +import com.aamdigital.aambackendservice.reporting.notification.dto.WebhookTarget +import com.aamdigital.aambackendservice.reporting.notification.storage.WebhookOwner +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono +import java.security.Principal + +data class WebhookAuthenticationWriteDto( + val type: WebhookAuthenticationType, + val apiKey: String, +) + +data class WebhookAuthenticationReadDto( + val type: String, +) + +data class WebhookDto( + val id: String, + val label: String, + val target: WebhookTarget, + val authentication: WebhookAuthenticationReadDto, + val owner: WebhookOwner, + val reportSubscriptions: MutableList, +) + +data class CreateWebhookRequestDto( + val label: String, + val target: WebhookTarget, + val authentication: WebhookAuthenticationWriteDto, +) + +@RestController +@RequestMapping("/v1/reporting/webhook") +@Validated +class WebhookController( + private val notificationStorage: NotificationStorage, + private val addWebhookSubscriptionUseCase: AddWebhookSubscriptionUseCase, +) { + + @GetMapping + fun fetchWebhooks( + principal: Principal, + ): Mono> { + return notificationStorage.fetchAllWebhooks().map { webhooks -> + webhooks + .filter { + it.owner.creator == principal.name + } + .map { + mapToDto(it) + } + } + } + + @GetMapping("/{webhookId}") + fun fetchWebhook( + @PathVariable webhookId: String, + principal: Principal, + ): Mono { + return notificationStorage.fetchWebhook(DomainReference(webhookId)) + .handle { webhook, sink -> + if (webhook.owner.creator == principal.name) { + sink.next(mapToDto(webhook)) + } else { + sink.error(ForbiddenAccessException()) + } + } + } + + @PostMapping + fun storeWebhook( + @RequestBody request: CreateWebhookRequestDto, + principal: Principal, + ): Mono { + return notificationStorage.createWebhook( + CreateWebhookRequest( + user = principal.name, + label = request.label, + target = request.target, + authentication = request.authentication + ) + ).map { + DomainReference(it.id) + } + } + + @PostMapping("/{webhookId}/subscribe/report/{reportId}") + fun registerReportNotification( + @PathVariable webhookId: String, + @PathVariable reportId: String, + ): Mono { + return addWebhookSubscriptionUseCase.subscribe( + report = DomainReference(reportId), + webhook = DomainReference(webhookId) + ) + } + + @DeleteMapping("/{webhookId}/subscribe/report/{reportId}") + fun unregisterReportNotification( + @PathVariable webhookId: String, + @PathVariable reportId: String, + ): Mono { + return notificationStorage.removeSubscription( + DomainReference(webhookId), + DomainReference(reportId) + ) + } + + private fun mapToDto(it: Webhook): WebhookDto { + return WebhookDto( + id = it.id, + label = it.label, + target = it.target, + authentication = WebhookAuthenticationReadDto( + type = it.authentication.type.toString() + ), + owner = it.owner, + reportSubscriptions = it.reportSubscriptions.map { it.id }.toMutableList() + ) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/AddWebhookSubscriptionUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/AddWebhookSubscriptionUseCase.kt new file mode 100644 index 0000000..9632b64 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/AddWebhookSubscriptionUseCase.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.reporting.notification.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import reactor.core.publisher.Mono + +interface AddWebhookSubscriptionUseCase { + fun subscribe(report: DomainReference, webhook: DomainReference): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultAddWebhookSubscriptionUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultAddWebhookSubscriptionUseCase.kt new file mode 100644 index 0000000..0cdaaf2 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultAddWebhookSubscriptionUseCase.kt @@ -0,0 +1,45 @@ +package com.aamdigital.aambackendservice.reporting.notification.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationRequest +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationUseCase +import reactor.core.publisher.Mono + +class DefaultAddWebhookSubscriptionUseCase( + private val notificationStorage: NotificationStorage, + private val reportingStorage: ReportingStorage, + private val notificationService: NotificationService, + private val createReportCalculationUseCase: CreateReportCalculationUseCase +) : AddWebhookSubscriptionUseCase { + override fun subscribe(report: DomainReference, webhook: DomainReference): Mono { + return notificationStorage.addSubscription( + webhookRef = webhook, + entityRef = report + ).flatMap { + reportingStorage.fetchCalculations( + reportReference = report + ).flatMap { calculations -> + if (calculations.isEmpty()) { + createReportCalculationUseCase.startReportCalculation( + CreateReportCalculationRequest( + report = report, + ) + ).flatMap { + Mono.just(Unit) + } + } else { + notificationService.triggerWebhook( + report = report, + webhook = webhook, + reportCalculation = DomainReference( + calculations.sortedByDescending { it.endDate }.first().id + ) + ) + Mono.just(Unit) + } + } + } + + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventConsumer.kt new file mode 100644 index 0000000..c5d5df6 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventConsumer.kt @@ -0,0 +1,58 @@ +package com.aamdigital.aambackendservice.reporting.notification.core + +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.queue.core.QueueMessageParser +import com.aamdigital.aambackendservice.reporting.domain.event.NotificationEvent +import com.aamdigital.aambackendservice.reporting.notification.di.NotificationQueueConfiguration.Companion.NOTIFICATION_QUEUE +import com.rabbitmq.client.Channel +import org.slf4j.LoggerFactory +import org.springframework.amqp.AmqpRejectAndDontRequeueException +import org.springframework.amqp.core.Message +import org.springframework.amqp.rabbit.annotation.RabbitListener +import reactor.core.publisher.Mono + +class DefaultNotificationEventConsumer( + private val messageParser: QueueMessageParser, + private val useCase: TriggerWebhookUseCase, +) : NotificationEventConsumer { + + private val logger = LoggerFactory.getLogger(javaClass) + + @RabbitListener( + queues = [NOTIFICATION_QUEUE], + ackMode = "MANUAL" + ) + override fun consume(rawMessage: String, message: Message, channel: Channel): Mono { + val type = try { + messageParser.getTypeKClass(rawMessage.toByteArray()) + } catch (ex: AamException) { + return Mono.error { throw AmqpRejectAndDontRequeueException("[${ex.code}] ${ex.localizedMessage}", ex) } + } + + when (type.qualifiedName) { + NotificationEvent::class.qualifiedName -> { + val payload = messageParser.getPayload( + body = rawMessage.toByteArray(), + kClass = NotificationEvent::class + ) + + logger.debug("Payload parsed: {}", payload) + + return useCase.trigger(payload).flatMap { + Mono.empty() + } + } + + else -> { + logger.warn( + "[DefaultNotificationEventConsumer] Could not find any use case for this EventType: {}", + type.qualifiedName, + ) + + throw AmqpRejectAndDontRequeueException( + "[NO_USECASE_CONFIGURED] Could not found matching use case for: ${type.qualifiedName}", + ) + } + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventPublisher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventPublisher.kt new file mode 100644 index 0000000..358705b --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventPublisher.kt @@ -0,0 +1,56 @@ +package com.aamdigital.aambackendservice.reporting.notification.core + +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.error.InternalServerException +import com.aamdigital.aambackendservice.queue.core.QueueMessage +import com.aamdigital.aambackendservice.reporting.domain.event.NotificationEvent +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.amqp.AmqpException +import org.springframework.amqp.rabbit.core.RabbitTemplate +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.* + +class DefaultNotificationEventPublisher( + private val objectMapper: ObjectMapper, + private val rabbitTemplate: RabbitTemplate, +) : NotificationEventPublisher { + + private val logger = LoggerFactory.getLogger(javaClass) + + @Throws(AamException::class) + override fun publish(channel: String, event: NotificationEvent): QueueMessage { + val message = QueueMessage( + id = UUID.randomUUID(), + type = NotificationEvent::class.java.canonicalName, + payload = event, + createdAt = Instant.now() + .atOffset(ZoneOffset.UTC) + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + ) + + try { + rabbitTemplate.convertAndSend( + channel, + objectMapper.writeValueAsString(message) + ) + } catch (ex: AmqpException) { + throw InternalServerException( + message = "Could not publish NotificationEvent: $event", + code = "EVENT_PUBLISH_ERROR", + cause = ex + ) + } + + logger.trace( + "[DefaultNotificationEventPublisher]: publish message to channel '{}' Payload: {}", + channel, + jacksonObjectMapper().writeValueAsString(message) + ) + + return message + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultTriggerWebhookUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultTriggerWebhookUseCase.kt new file mode 100644 index 0000000..cbf221e --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultTriggerWebhookUseCase.kt @@ -0,0 +1,68 @@ +package com.aamdigital.aambackendservice.reporting.notification.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.reporting.domain.event.NotificationEvent +import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono +import java.net.URI + +class DefaultTriggerWebhookUseCase( + private val notificationStorage: NotificationStorage, + private val webClient: WebClient, + private val uriParser: UriParser, +) : TriggerWebhookUseCase { + private val logger = LoggerFactory.getLogger(javaClass) + + override fun trigger(notificationEvent: NotificationEvent): Mono { + return notificationStorage.fetchWebhook( + webhookRef = DomainReference(notificationEvent.webhookId) + ) + .flatMap { webhook -> + val uri = URI( + uriParser.replacePlaceholder( + webhook.target.url, + mapOf( + Pair("reportId", notificationEvent.reportId) + ) + ) + ) + + webClient + .method(HttpMethod.valueOf(webhook.target.method)) + .uri { + it.scheme(uri.scheme) + it.host(uri.host) + it.path(uri.path) + it.build() + } + .headers { + it.set(HttpHeaders.AUTHORIZATION, "Token ${webhook.authentication.secret}") + } + .body( + BodyInserters.fromValue( + mapOf( + Pair("calculation_id", notificationEvent.calculationId) + ) + ) + ) + .accept(MediaType.APPLICATION_JSON) + .exchangeToMono { response -> + response.bodyToMono(String::class.java) + } + .map { + logger.trace( + "[DefaultTriggerWebhookUseCase] Webhook trigger completed for Webhook: {} Report: {} Calculation: {} - Response: {}", + notificationEvent.webhookId, + notificationEvent.reportId, + notificationEvent.calculationId, + it + ) + } + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultUriParser.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultUriParser.kt new file mode 100644 index 0000000..dc9094c --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultUriParser.kt @@ -0,0 +1,13 @@ +package com.aamdigital.aambackendservice.reporting.notification.core + +class DefaultUriParser : UriParser { + override fun replacePlaceholder(url: String, values: Map): String { + var modifiedUrl = url + + for ((key, value) in values) { + modifiedUrl = modifiedUrl.replace("<$key>", value) + } + + return modifiedUrl + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationEventConsumer.kt new file mode 100644 index 0000000..54dc398 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationEventConsumer.kt @@ -0,0 +1,9 @@ +package com.aamdigital.aambackendservice.reporting.notification.core + +import com.rabbitmq.client.Channel +import org.springframework.amqp.core.Message +import reactor.core.publisher.Mono + +interface NotificationEventConsumer { + fun consume(rawMessage: String, message: Message, channel: Channel): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationEventPublisher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationEventPublisher.kt new file mode 100644 index 0000000..7644d39 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationEventPublisher.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.reporting.notification.core + +import com.aamdigital.aambackendservice.queue.core.QueueMessage +import com.aamdigital.aambackendservice.reporting.domain.event.NotificationEvent + +interface NotificationEventPublisher { + fun publish(channel: String, event: NotificationEvent): QueueMessage +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationService.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationService.kt new file mode 100644 index 0000000..a42d491 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationService.kt @@ -0,0 +1,55 @@ +package com.aamdigital.aambackendservice.reporting.notification.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.reporting.domain.event.NotificationEvent +import com.aamdigital.aambackendservice.reporting.notification.di.NotificationQueueConfiguration +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono + +@Service +class NotificationService( + private val notificationStorage: NotificationStorage, + private val notificationEventPublisher: NotificationEventPublisher +) { + private val logger = LoggerFactory.getLogger(javaClass) + + fun getAffectedWebhooks(report: DomainReference): Mono> { + return notificationStorage.fetchAllWebhooks() + .map { webhooks -> + val affectedWebhooks: MutableList = mutableListOf() + webhooks.forEach { webhook -> + if (webhook.reportSubscriptions.contains(report)) { + affectedWebhooks.add(DomainReference(webhook.id)) + } + } + affectedWebhooks + } + } + + fun sendNotifications(report: DomainReference, reportCalculation: DomainReference): Mono { + logger.trace("[NotificationService]: Trigger all affected webhooks for ${report.id}") + return getAffectedWebhooks(report) + .map { webhooks -> + webhooks.map { webhook -> + triggerWebhook( + report = report, + reportCalculation = reportCalculation, + webhook = webhook + ) + } + } + } + + fun triggerWebhook(report: DomainReference, reportCalculation: DomainReference, webhook: DomainReference) { + logger.trace("[NotificationService]: Trigger NotificationEvent for ${webhook.id} and ${report.id}") + notificationEventPublisher.publish( + NotificationQueueConfiguration.NOTIFICATION_QUEUE, + NotificationEvent( + webhookId = webhook.id, + reportId = report.id, + calculationId = reportCalculation.id + ) + ) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationStorage.kt new file mode 100644 index 0000000..a93322a --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationStorage.kt @@ -0,0 +1,22 @@ +package com.aamdigital.aambackendservice.reporting.notification.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.reporting.notification.controller.WebhookAuthenticationWriteDto +import com.aamdigital.aambackendservice.reporting.notification.dto.Webhook +import com.aamdigital.aambackendservice.reporting.notification.dto.WebhookTarget +import reactor.core.publisher.Mono + +data class CreateWebhookRequest( + val user: String, + val label: String, + val target: WebhookTarget, + val authentication: WebhookAuthenticationWriteDto, +) + +interface NotificationStorage { + fun addSubscription(webhookRef: DomainReference, entityRef: DomainReference): Mono + fun removeSubscription(webhookRef: DomainReference, entityRef: DomainReference): Mono + fun fetchAllWebhooks(): Mono> + fun fetchWebhook(webhookRef: DomainReference): Mono + fun createWebhook(request: CreateWebhookRequest): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/TriggerWebhookUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/TriggerWebhookUseCase.kt new file mode 100644 index 0000000..562b823 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/TriggerWebhookUseCase.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.reporting.notification.core + +import com.aamdigital.aambackendservice.reporting.domain.event.NotificationEvent +import reactor.core.publisher.Mono + +interface TriggerWebhookUseCase { + fun trigger(notificationEvent: NotificationEvent): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/UriParser.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/UriParser.kt new file mode 100644 index 0000000..a59e3df --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/UriParser.kt @@ -0,0 +1,5 @@ +package com.aamdigital.aambackendservice.reporting.notification.core + +interface UriParser { + fun replacePlaceholder(url: String, values: Map): String +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationConfiguration.kt new file mode 100644 index 0000000..c2fe3a3 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationConfiguration.kt @@ -0,0 +1,63 @@ +package com.aamdigital.aambackendservice.reporting.notification.di + +import com.aamdigital.aambackendservice.crypto.core.CryptoService +import com.aamdigital.aambackendservice.reporting.notification.core.AddWebhookSubscriptionUseCase +import com.aamdigital.aambackendservice.reporting.notification.core.DefaultAddWebhookSubscriptionUseCase +import com.aamdigital.aambackendservice.reporting.notification.core.DefaultTriggerWebhookUseCase +import com.aamdigital.aambackendservice.reporting.notification.core.DefaultUriParser +import com.aamdigital.aambackendservice.reporting.notification.core.NotificationService +import com.aamdigital.aambackendservice.reporting.notification.core.NotificationStorage +import com.aamdigital.aambackendservice.reporting.notification.core.TriggerWebhookUseCase +import com.aamdigital.aambackendservice.reporting.notification.core.UriParser +import com.aamdigital.aambackendservice.reporting.notification.storage.DefaultNotificationStorage +import com.aamdigital.aambackendservice.reporting.notification.storage.WebhookRepository +import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationUseCase +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient + +@Configuration +class NotificationConfiguration { + + @Bean + fun defaultAddWebhookSubscription( + notificationStorage: NotificationStorage, + reportingStorage: ReportingStorage, + notificationService: NotificationService, + createReportCalculationUseCase: CreateReportCalculationUseCase + ): AddWebhookSubscriptionUseCase = + DefaultAddWebhookSubscriptionUseCase( + notificationStorage = notificationStorage, + reportingStorage = reportingStorage, + notificationService = notificationService, + createReportCalculationUseCase = createReportCalculationUseCase + ) + + @Bean + fun defaultUriParser(): UriParser = DefaultUriParser() + + @Bean + fun defaultTriggerWebhookUseCase( + notificationStorage: NotificationStorage, + @Qualifier("webhook-web-client") webClient: WebClient, + uriParser: UriParser, + ): TriggerWebhookUseCase = DefaultTriggerWebhookUseCase(notificationStorage, webClient, uriParser) + + @Bean(name = ["webhook-web-client"]) + fun webhookWebClient(): WebClient { + val clientBuilder = + WebClient.builder() + + return clientBuilder.clientConnector(ReactorClientHttpConnector(HttpClient.create())).build() + } + + @Bean + fun defaultNotificationStorage( + webhookRepository: WebhookRepository, + cryptoService: CryptoService, + ): NotificationStorage = DefaultNotificationStorage(webhookRepository, cryptoService) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationQueueConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationQueueConfiguration.kt new file mode 100644 index 0000000..306f9e7 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationQueueConfiguration.kt @@ -0,0 +1,64 @@ +package com.aamdigital.aambackendservice.reporting.notification.di + +import com.aamdigital.aambackendservice.queue.core.QueueMessageParser +import com.aamdigital.aambackendservice.reporting.notification.core.DefaultNotificationEventConsumer +import com.aamdigital.aambackendservice.reporting.notification.core.DefaultNotificationEventPublisher +import com.aamdigital.aambackendservice.reporting.notification.core.NotificationEventConsumer +import com.aamdigital.aambackendservice.reporting.notification.core.NotificationEventPublisher +import com.aamdigital.aambackendservice.reporting.notification.core.TriggerWebhookUseCase +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.amqp.core.Binding +import org.springframework.amqp.core.BindingBuilder +import org.springframework.amqp.core.FanoutExchange +import org.springframework.amqp.core.Queue +import org.springframework.amqp.core.QueueBuilder +import org.springframework.amqp.rabbit.core.RabbitTemplate +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class NotificationQueueConfiguration { + + companion object { + const val NOTIFICATION_QUEUE = "notification.webhook" + const val NOTIFICATION_DEAD_LETTER_QUEUE = "notification.webhook.deadLetter" + const val NOTIFICATION_DEAD_LETTER_EXCHANGE = "$NOTIFICATION_QUEUE.dlx" + } + + @Bean("notification-queue") + fun notificationQueue(): Queue = QueueBuilder + .durable(NOTIFICATION_QUEUE) + .deadLetterExchange(NOTIFICATION_DEAD_LETTER_EXCHANGE) + .build() + + @Bean("notification-dead-letter-queue") + fun notificationDeadLetterQueue(): Queue = QueueBuilder + .durable(NOTIFICATION_DEAD_LETTER_QUEUE) + .build() + + @Bean("notification-dead-letter-exchange") + fun notificationDeadLetterExchange(): FanoutExchange = + FanoutExchange(NOTIFICATION_DEAD_LETTER_EXCHANGE) + + @Bean + fun notificationDeadLetterBinding( + @Qualifier("notification-dead-letter-queue") notificationDeadLetterQueue: Queue, + @Qualifier("notification-dead-letter-exchange") notificationDeadLetterExchange: FanoutExchange, + ): Binding = + BindingBuilder.bind(notificationDeadLetterQueue).to(notificationDeadLetterExchange) + + @Bean + fun defaultNotificationEventPublisher( + rabbitTemplate: RabbitTemplate, + objectMapper: ObjectMapper, + ): NotificationEventPublisher = DefaultNotificationEventPublisher( + objectMapper = objectMapper, rabbitTemplate = rabbitTemplate + ) + + @Bean + fun defaultNotificationEventConsumer( + messageParser: QueueMessageParser, + useCase: TriggerWebhookUseCase, + ): NotificationEventConsumer = DefaultNotificationEventConsumer(messageParser, useCase) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/dto/Dto.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/dto/Dto.kt new file mode 100644 index 0000000..298229a --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/dto/Dto.kt @@ -0,0 +1,27 @@ +package com.aamdigital.aambackendservice.reporting.notification.dto + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.reporting.notification.storage.WebhookOwner + +class WebhookAuthentication( + var type: WebhookAuthenticationType, + val secret: String, +) + +data class WebhookTarget( + val method: String, + val url: String, +) + +enum class WebhookAuthenticationType { + API_KEY +} + +data class Webhook( + val id: String, + val label: String, + val target: WebhookTarget, + val authentication: WebhookAuthentication, + val owner: WebhookOwner, + val reportSubscriptions: MutableList = mutableListOf() +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/DefaultNotificationStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/DefaultNotificationStorage.kt new file mode 100644 index 0000000..5622791 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/DefaultNotificationStorage.kt @@ -0,0 +1,103 @@ +package com.aamdigital.aambackendservice.reporting.notification.storage + +import com.aamdigital.aambackendservice.crypto.core.CryptoService +import com.aamdigital.aambackendservice.crypto.core.EncryptedData +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.reporting.notification.core.CreateWebhookRequest +import com.aamdigital.aambackendservice.reporting.notification.core.NotificationStorage +import com.aamdigital.aambackendservice.reporting.notification.dto.Webhook +import com.aamdigital.aambackendservice.reporting.notification.dto.WebhookAuthentication +import reactor.core.publisher.Mono +import java.util.* + +class DefaultNotificationStorage( + private val webhookRepository: WebhookRepository, + private val cryptoService: CryptoService, +) : NotificationStorage { + override fun addSubscription(webhookRef: DomainReference, entityRef: DomainReference): Mono { + return webhookRepository.fetchWebhook( + webhookRef = webhookRef + ) + .map { webhook -> + if (webhook.reportSubscriptions.indexOf(entityRef.id) == -1) { + webhook.reportSubscriptions.add(entityRef.id) + } + webhook + } + .flatMap { webhook -> + webhookRepository.storeWebhook(webhook) + } + .map { } + } + + override fun removeSubscription(webhookRef: DomainReference, entityRef: DomainReference): Mono { + return webhookRepository.fetchWebhook( + webhookRef = webhookRef + ) + .map { document -> + document.reportSubscriptions.remove(entityRef.id) + document + } + .flatMap { + webhookRepository.storeWebhook(it) + } + .map { } + } + + override fun fetchAllWebhooks(): Mono> { + return webhookRepository.fetchAllWebhooks().map { entities -> + entities.map { mapFromEntity(it) } + } + } + + override fun fetchWebhook(webhookRef: DomainReference): Mono { + return webhookRepository.fetchWebhook(webhookRef = webhookRef).map { + mapFromEntity(it) + } + } + + override fun createWebhook(request: CreateWebhookRequest): Mono { + val encryptedKey = cryptoService.encrypt(request.authentication.apiKey) + val newId = "Webhook:${UUID.randomUUID()}" + + return webhookRepository.storeWebhook( + webhook = WebhookEntity( + id = newId, + label = request.label, + target = request.target, + authentication = WebhookAuthenticationEntity( + type = request.authentication.type, + data = encryptedKey.data, + iv = encryptedKey.iv, + ), + owner = WebhookOwner( + creator = request.user, + roles = emptyList(), + users = emptyList(), + groups = emptyList(), + ), + reportSubscriptions = mutableListOf() + ) + ).map { + DomainReference(newId) + } + } + + private fun mapFromEntity(entity: WebhookEntity): Webhook = + Webhook( + id = entity.id, + label = entity.label, + target = entity.target, + authentication = WebhookAuthentication( + type = entity.authentication.type, + secret = cryptoService.decrypt( + EncryptedData( + iv = entity.authentication.iv, + data = entity.authentication.data + ) + ), + ), + owner = entity.owner, + reportSubscriptions = entity.reportSubscriptions.map { DomainReference(it) }.toMutableList() + ) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/WebhookEntity.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/WebhookEntity.kt new file mode 100644 index 0000000..8f7093a --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/WebhookEntity.kt @@ -0,0 +1,26 @@ +package com.aamdigital.aambackendservice.reporting.notification.storage + +import com.aamdigital.aambackendservice.reporting.notification.dto.WebhookAuthenticationType +import com.aamdigital.aambackendservice.reporting.notification.dto.WebhookTarget + +data class WebhookAuthenticationEntity( + var type: WebhookAuthenticationType, + val iv: String, + val data: String, +) + +data class WebhookOwner( + val creator: String, + val users: List = emptyList(), + val groups: List = emptyList(), + val roles: List = emptyList(), +) + +data class WebhookEntity( + val id: String, + val label: String, + val target: WebhookTarget, + val authentication: WebhookAuthenticationEntity, + val owner: WebhookOwner, + val reportSubscriptions: MutableList = mutableListOf() +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/WebhookRepository.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/WebhookRepository.kt new file mode 100644 index 0000000..a341950 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/storage/WebhookRepository.kt @@ -0,0 +1,66 @@ +package com.aamdigital.aambackendservice.reporting.notification.storage + +import com.aamdigital.aambackendservice.couchdb.core.CouchDbStorage +import com.aamdigital.aambackendservice.couchdb.core.getQueryParamsAllDocs +import com.aamdigital.aambackendservice.couchdb.dto.DocSuccess +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.InternalServerException +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import reactor.core.publisher.Mono + +@Service +class WebhookRepository( + private val couchDbStorage: CouchDbStorage, + private val objectMapper: ObjectMapper, +) { + private val logger = LoggerFactory.getLogger(javaClass) + + companion object { + private const val WEBHOOK_DATABASE = "notification-webhook" + } + + fun fetchAllWebhooks(): Mono> { + return couchDbStorage + .getDatabaseDocument( + database = WEBHOOK_DATABASE, + documentId = "_all_docs", + queryParams = getQueryParamsAllDocs("Webhook"), + kClass = ObjectNode::class + ).map { objectNode -> + val data = + (objectMapper.convertValue(objectNode, Map::class.java)["rows"] as Iterable<*>) + .map { entry -> + if (entry is LinkedHashMap<*, *>) { + objectMapper.convertValue(entry["doc"], WebhookEntity::class.java) + } else { + throw InternalServerException("Invalid response") + } + } + + data + } + } + + fun fetchWebhook(webhookRef: DomainReference): Mono { + return couchDbStorage + .getDatabaseDocument( + database = WEBHOOK_DATABASE, + documentId = webhookRef.id, + queryParams = LinkedMultiValueMap(), + kClass = WebhookEntity::class + ) + } + + fun storeWebhook(webhook: WebhookEntity): Mono { + return couchDbStorage + .putDatabaseDocument( + database = WEBHOOK_DATABASE, + documentId = webhook.id, + body = webhook, + ) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultIdentifyAffectedReportsUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultIdentifyAffectedReportsUseCase.kt new file mode 100644 index 0000000..3d629f3 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultIdentifyAffectedReportsUseCase.kt @@ -0,0 +1,35 @@ +package com.aamdigital.aambackendservice.reporting.report.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent +import org.slf4j.LoggerFactory +import reactor.core.publisher.Mono + +class DefaultIdentifyAffectedReportsUseCase( + private val reportingStorage: ReportingStorage, + private val reportSchemaGenerator: ReportSchemaGenerator, +) : IdentifyAffectedReportsUseCase { + + val logger = LoggerFactory.getLogger(javaClass) + + override fun analyse(documentChangeEvent: DocumentChangeEvent): Mono> { + + val changedEntity = documentChangeEvent.documentId.split(":").first() + + return reportingStorage.fetchAllReports("sql") + .map { reports -> + val affectedReports: MutableList = mutableListOf() + reports.forEach { report -> + // todo better change detection (fields) + val affectedEntities = reportSchemaGenerator.getAffectedEntities(report) + val affected = affectedEntities.any { + it == changedEntity + } + if (affected) { + affectedReports.add(DomainReference(report.id)) + } + } + affectedReports + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportCalculationProcessor.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportCalculationProcessor.kt new file mode 100644 index 0000000..47e87ba --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportCalculationProcessor.kt @@ -0,0 +1,69 @@ +package com.aamdigital.aambackendservice.reporting.report.core + +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculationOutcome +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculationStatus +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.ReportCalculator +import reactor.core.publisher.Mono +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.* + +class DefaultReportCalculationProcessor( + private val reportingStorage: ReportingStorage, + private val reportCalculator: ReportCalculator, +) : ReportCalculationProcessor { + override fun processNextPendingCalculation(): Mono { + var calculation: ReportCalculation; + return reportingStorage.fetchPendingCalculations() + .flatMap { calculations -> + calculation = calculations.firstOrNull() + ?: return@flatMap Mono.just(Unit) + + reportingStorage.storeCalculation( + reportCalculation = calculation + .setStatus(ReportCalculationStatus.RUNNING) + .setStartDate( + startDate = getIsoLocalDateTime() + ) + ) + .flatMap { + reportCalculator.calculate(reportCalculation = it) + } + .flatMap { reportData -> + reportingStorage.storeData( + reportData + ) + } + .flatMap { reportData -> + reportingStorage.storeCalculation( + reportCalculation = calculation + .setStatus(ReportCalculationStatus.FINISHED_SUCCESS) + .setOutcome( + ReportCalculationOutcome.Success( + resultHash = reportData.getDataHash() + ) + ) + .setEndDate(getIsoLocalDateTime()) + ).map {} + } + .onErrorResume { + reportingStorage.storeCalculation( + reportCalculation = calculation + .setStatus(ReportCalculationStatus.FINISHED_ERROR) + .setOutcome( + ReportCalculationOutcome.Failure( + errorCode = "CALCULATION_FAILED", + errorMessage = it.localizedMessage, + ) + ) + .setEndDate(getIsoLocalDateTime()) + ).map {} + } + } + } + + private fun getIsoLocalDateTime(): String = Date().toInstant() + .atOffset(ZoneOffset.UTC) + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumer.kt new file mode 100644 index 0000000..56f015a --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumer.kt @@ -0,0 +1,99 @@ +package com.aamdigital.aambackendservice.reporting.report.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.queue.core.QueueMessageParser +import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent +import com.aamdigital.aambackendservice.reporting.report.di.ReportQueueConfiguration +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationRequest +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationUseCase +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.ReportCalculationChangeUseCase +import com.rabbitmq.client.Channel +import org.slf4j.LoggerFactory +import org.springframework.amqp.AmqpRejectAndDontRequeueException +import org.springframework.amqp.core.Message +import org.springframework.amqp.rabbit.annotation.RabbitListener +import reactor.core.publisher.Mono + +class DefaultReportDocumentChangeEventConsumer( + private val messageParser: QueueMessageParser, + private val createReportCalculationUseCase: CreateReportCalculationUseCase, + private val reportCalculationChangeUseCase: ReportCalculationChangeUseCase, + private val identifyAffectedReportsUseCase: IdentifyAffectedReportsUseCase, +) : ReportDocumentChangeEventConsumer { + + private val logger = LoggerFactory.getLogger(javaClass) + + @RabbitListener( + queues = [ReportQueueConfiguration.DOCUMENT_CHANGES_REPORT_QUEUE], + ackMode = "MANUAL" + ) + override fun consume(rawMessage: String, message: Message, channel: Channel): Mono { + val type = try { + messageParser.getTypeKClass(rawMessage.toByteArray()) + } catch (ex: AamException) { + return Mono.error { throw AmqpRejectAndDontRequeueException("[${ex.code}] ${ex.localizedMessage}", ex) } + } + + when (type.qualifiedName) { + DocumentChangeEvent::class.qualifiedName -> { + val payload = messageParser.getPayload( + body = rawMessage.toByteArray(), + kClass = DocumentChangeEvent::class + ) + + if (payload.documentId.startsWith("ReportConfig:")) { + logger.info(payload.toString()) + + // todo if aggregationDefinition is different, skip trigger ReportCalculation + + val reportRef = payload.currentVersion["_id"] as String + + return createReportCalculationUseCase.startReportCalculation( + request = CreateReportCalculationRequest( + report = DomainReference(reportRef) + ) + ).flatMap { Mono.empty() } + } + + if (payload.documentId.startsWith("ReportCalculation:")) { + if (payload.deleted) { + return Mono.empty() + } + return reportCalculationChangeUseCase.handle( + documentChangeEvent = payload + ).flatMap { Mono.empty() } + } + + return identifyAffectedReportsUseCase.analyse( + documentChangeEvent = payload + ) + .flatMap { affectedReports -> + Mono.zip( + affectedReports.map { report -> + createReportCalculationUseCase + .startReportCalculation( + request = CreateReportCalculationRequest( + report = report + ) + ) + } + ) { it.map { } } + } + .flatMap { Mono.empty() } + + } + + else -> { + logger.warn( + "[DefaultReportDocumentChangeEventConsumer] Could not find any use case for this EventType: {}", + type.qualifiedName, + ) + + throw AmqpRejectAndDontRequeueException( + "[NO_USECASE_CONFIGURED] Could not found matching use case for: ${type.qualifiedName}", + ) + } + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/IdentifyAffectedReportsUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/IdentifyAffectedReportsUseCase.kt new file mode 100644 index 0000000..dc213ca --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/IdentifyAffectedReportsUseCase.kt @@ -0,0 +1,9 @@ +package com.aamdigital.aambackendservice.reporting.report.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent +import reactor.core.publisher.Mono + +interface IdentifyAffectedReportsUseCase { + fun analyse(documentChangeEvent: DocumentChangeEvent): Mono> +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/NoopReportCalculationProcessor.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/NoopReportCalculationProcessor.kt new file mode 100644 index 0000000..ce8edce --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/NoopReportCalculationProcessor.kt @@ -0,0 +1,9 @@ +package com.aamdigital.aambackendservice.reporting.report.core + +import reactor.core.publisher.Mono + +class NoopReportCalculationProcessor : ReportCalculationProcessor { + override fun processNextPendingCalculation(): Mono { + return Mono.just(Unit) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/QueryStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/QueryStorage.kt new file mode 100644 index 0000000..97d9968 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/QueryStorage.kt @@ -0,0 +1,9 @@ +package com.aamdigital.aambackendservice.reporting.report.core + +import com.aamdigital.aambackendservice.reporting.report.sqs.QueryRequest +import com.aamdigital.aambackendservice.reporting.report.sqs.QueryResult +import reactor.core.publisher.Mono + +interface QueryStorage { + fun executeQuery(query: QueryRequest): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportCalculationProcessor.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportCalculationProcessor.kt new file mode 100644 index 0000000..5151922 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportCalculationProcessor.kt @@ -0,0 +1,7 @@ +package com.aamdigital.aambackendservice.reporting.report.core + +import reactor.core.publisher.Mono + +interface ReportCalculationProcessor { + fun processNextPendingCalculation(): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportDocumentChangeEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportDocumentChangeEventConsumer.kt new file mode 100644 index 0000000..a074631 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportDocumentChangeEventConsumer.kt @@ -0,0 +1,9 @@ +package com.aamdigital.aambackendservice.reporting.report.core + +import com.rabbitmq.client.Channel +import org.springframework.amqp.core.Message +import reactor.core.publisher.Mono + +interface ReportDocumentChangeEventConsumer { + fun consume(rawMessage: String, message: Message, channel: Channel): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportSchemaGenerator.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportSchemaGenerator.kt new file mode 100644 index 0000000..ebd1267 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportSchemaGenerator.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.reporting.report.core + +import com.aamdigital.aambackendservice.reporting.domain.Report + +interface ReportSchemaGenerator { + fun getTableNamesByQuery(query: String): List + fun getAffectedEntities(report: Report): List +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportingStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportingStorage.kt new file mode 100644 index 0000000..b2a2c31 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/ReportingStorage.kt @@ -0,0 +1,26 @@ +package com.aamdigital.aambackendservice.reporting.report.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.reporting.domain.Report +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation +import com.aamdigital.aambackendservice.reporting.domain.ReportData +import reactor.core.publisher.Mono +import java.util.* + +interface ReportingStorage { + fun fetchAllReports(mode: String): Mono> + fun fetchReport( + report: DomainReference + ): Mono> + + fun fetchPendingCalculations(): Mono> + fun fetchCalculations(reportReference: DomainReference): Mono> + fun fetchCalculation( + calculationReference: DomainReference + ): Mono> + + fun storeCalculation(reportCalculation: ReportCalculation): Mono + fun storeData(reportData: ReportData): Mono + fun fetchData(calculationReference: DomainReference): Mono> + fun isCalculationOngoing(reportReference: DomainReference): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/SimpleReportSchemaGenerator.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/SimpleReportSchemaGenerator.kt new file mode 100644 index 0000000..c5573c0 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/SimpleReportSchemaGenerator.kt @@ -0,0 +1,29 @@ +package com.aamdigital.aambackendservice.reporting.report.core + +import com.aamdigital.aambackendservice.reporting.domain.Report +import org.springframework.stereotype.Service +import java.util.regex.Pattern + +@Service +class SimpleReportSchemaGenerator : ReportSchemaGenerator { + override fun getTableNamesByQuery(query: String): List { + val pattern: Pattern = Pattern.compile("\\bas\\s+(\\w+)*(?=\\s*,|\\s*FROM)") + val fieldNames: MutableList = ArrayList() + val matcher = pattern.matcher(query) + + while (matcher.find()) { + fieldNames.add(matcher.group(1)) + } + + return fieldNames + } + + override fun getAffectedEntities(report: Report): List { + val sqlFromTableRegex: Pattern = Pattern.compile("FROM\\s+(\\w+)") + return sqlFromTableRegex + .matcher(report.query) + .results() + .map { it.group(1) } + .toList() + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/ReportConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/ReportConfiguration.kt new file mode 100644 index 0000000..a9327be --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/ReportConfiguration.kt @@ -0,0 +1,46 @@ +package com.aamdigital.aambackendservice.reporting.report.di + +import com.aamdigital.aambackendservice.reporting.report.core.DefaultIdentifyAffectedReportsUseCase +import com.aamdigital.aambackendservice.reporting.report.core.DefaultReportCalculationProcessor +import com.aamdigital.aambackendservice.reporting.report.core.IdentifyAffectedReportsUseCase +import com.aamdigital.aambackendservice.reporting.report.core.NoopReportCalculationProcessor +import com.aamdigital.aambackendservice.reporting.report.core.ReportCalculationProcessor +import com.aamdigital.aambackendservice.reporting.report.core.ReportSchemaGenerator +import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.ReportCalculator +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ReportConfiguration { + + @Bean + fun defaultIdentifyAffectedReportsUseCase( + reportingStorage: ReportingStorage, + schemaGenerator: ReportSchemaGenerator, + ): IdentifyAffectedReportsUseCase = + DefaultIdentifyAffectedReportsUseCase(reportingStorage, schemaGenerator) + + @Bean + @ConditionalOnProperty( + prefix = "report-calculation-processor", + name = ["enabled"], + havingValue = "false" + ) + fun noopReportCalculationProcessor(): ReportCalculationProcessor = NoopReportCalculationProcessor() + + @Bean + @ConditionalOnProperty( + prefix = "report-calculation-processor", + name = ["enabled"], + matchIfMissing = true + ) + fun defaultReportCalculationProcessor( + reportCalculator: ReportCalculator, + reportingStorage: ReportingStorage, + ): ReportCalculationProcessor = DefaultReportCalculationProcessor( + reportCalculator = reportCalculator, + reportingStorage = reportingStorage, + ) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/ReportQueueConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/ReportQueueConfiguration.kt new file mode 100644 index 0000000..4e65ea7 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/ReportQueueConfiguration.kt @@ -0,0 +1,49 @@ +package com.aamdigital.aambackendservice.reporting.report.di + +import com.aamdigital.aambackendservice.queue.core.QueueMessageParser +import com.aamdigital.aambackendservice.reporting.report.core.DefaultReportDocumentChangeEventConsumer +import com.aamdigital.aambackendservice.reporting.report.core.IdentifyAffectedReportsUseCase +import com.aamdigital.aambackendservice.reporting.report.core.ReportDocumentChangeEventConsumer +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationUseCase +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.ReportCalculationChangeUseCase +import org.springframework.amqp.core.Binding +import org.springframework.amqp.core.BindingBuilder +import org.springframework.amqp.core.FanoutExchange +import org.springframework.amqp.core.Queue +import org.springframework.amqp.core.QueueBuilder +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ReportQueueConfiguration { + + companion object { + const val DOCUMENT_CHANGES_REPORT_QUEUE = "document.changes.report" + } + + @Bean("report-config-changes-queue") + fun reportConfigChangesQueue(): Queue = QueueBuilder + .durable(DOCUMENT_CHANGES_REPORT_QUEUE) + .build() + + @Bean + fun documentChangesBinding( + @Qualifier("report-config-changes-queue") queue: Queue, + @Qualifier("document-changes-exchange") exchange: FanoutExchange, + ): Binding = BindingBuilder.bind(queue).to(exchange) + + @Bean + fun reportDocumentChangeEventConsumer( + messageParser: QueueMessageParser, + createReportCalculationUseCase: CreateReportCalculationUseCase, + reportCalculationChangeUseCase: ReportCalculationChangeUseCase, + identifyAffectedReportsUseCase: IdentifyAffectedReportsUseCase, + ): ReportDocumentChangeEventConsumer = + DefaultReportDocumentChangeEventConsumer( + messageParser, + createReportCalculationUseCase, + reportCalculationChangeUseCase, + identifyAffectedReportsUseCase, + ) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/SqsConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/SqsConfiguration.kt new file mode 100644 index 0000000..4f4aeca --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/SqsConfiguration.kt @@ -0,0 +1,31 @@ +package com.aamdigital.aambackendservice.reporting.report.di + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient + +@Configuration +class SqsConfiguration { + @Bean(name = ["sqs-client"]) + fun sqsWebClient(configuration: SqsClientConfiguration): WebClient { + val clientBuilder = + WebClient.builder().baseUrl(configuration.basePath) + .defaultHeaders { + it.setBasicAuth( + configuration.basicAuthUsername, + configuration.basicAuthPassword, + ) + } + return clientBuilder.clientConnector(ReactorClientHttpConnector(HttpClient.create())).build() + } +} + +@ConfigurationProperties("sqs-client-configuration") +class SqsClientConfiguration( + val basePath: String, + val basicAuthUsername: String, + val basicAuthPassword: String, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/dto/ControllerDtos.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/dto/ControllerDtos.kt new file mode 100644 index 0000000..9910e92 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/dto/ControllerDtos.kt @@ -0,0 +1,39 @@ +package com.aamdigital.aambackendservice.reporting.report.dto + +import com.aamdigital.aambackendservice.couchdb.dto.CouchDbRow +import com.aamdigital.aambackendservice.reporting.domain.ReportSchema +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * This is the interface shared to external users of the API endpoints. + */ +data class ReportDto( + val id: String, + val name: String, + val schema: ReportSchema?, + val calculationPending: Boolean +) + +data class ReportDoc( + @JsonProperty("_id") + val id: String, + @JsonProperty("_rev") + val rev: String, + val title: String, + val mode: String, + val aggregationDefinition: String, + val created: EditAtBy?, + val updated: EditAtBy?, +) + +data class EditAtBy( + val at: String, + val by: String, +) + +data class FetchReportsResponse( + @JsonProperty("total_rows") + val totalRows: Int, + val offset: Int, + val rows: List> +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/jobs/ReportCalculationJob.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/jobs/ReportCalculationJob.kt new file mode 100644 index 0000000..7bdfff1 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/jobs/ReportCalculationJob.kt @@ -0,0 +1,23 @@ +package com.aamdigital.aambackendservice.reporting.report.jobs + +import com.aamdigital.aambackendservice.reporting.report.core.ReportCalculationProcessor +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.Scheduled + +@Configuration +class ReportCalculationJob( + private val reportCalculationProcessor: ReportCalculationProcessor +) { + + private val logger = LoggerFactory.getLogger(javaClass) + + @Scheduled(fixedDelay = 10000) + fun handleReportCalculation() { + reportCalculationProcessor.processNextPendingCalculation() + .doOnError { + logger.error("[ReportCalculationJob] Error in job: processNextPendingCalculation()", it) + } + .subscribe {} + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsQueryStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsQueryStorage.kt new file mode 100644 index 0000000..97edd6b --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsQueryStorage.kt @@ -0,0 +1,56 @@ +package com.aamdigital.aambackendservice.reporting.report.sqs + +import com.aamdigital.aambackendservice.error.InvalidArgumentException +import com.aamdigital.aambackendservice.reporting.report.core.QueryStorage +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono + +data class QueryRequest( + val query: String +) + +data class QueryResult( + val result: List<*> +) + +@Service +class SqsQueryStorage( + @Qualifier("sqs-client") private val sqsClient: WebClient, + private val schemaService: SqsSchemaService, +) : QueryStorage { + private val logger = LoggerFactory.getLogger(javaClass) + + override fun executeQuery(query: QueryRequest): Mono { + val schemaPath = schemaService.getSchemaPath() + return schemaService.updateSchema() + .flatMap { + sqsClient.post() + .uri(schemaPath) + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(query)) + .accept(MediaType.APPLICATION_JSON) + .exchangeToMono { response -> + if (response.statusCode().is2xxSuccessful) { + response.bodyToMono(List::class.java) + .map { + QueryResult(result = it) + } + } else { + response.bodyToMono(String::class.java) + .flatMap { + logger.error("[SqsQueryStorage] Invalid response from SQS: $it") + Mono.error(InvalidArgumentException(it)) + } + } + } + } + .doOnError { + logger.error("[SqsQueryStorage]: ${it.localizedMessage}", it) + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsSchemaService.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsSchemaService.kt new file mode 100644 index 0000000..91a4a1a --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/sqs/SqsSchemaService.kt @@ -0,0 +1,201 @@ +package com.aamdigital.aambackendservice.reporting.report.sqs + +import com.aamdigital.aambackendservice.couchdb.core.CouchDbStorage +import com.aamdigital.aambackendservice.crypto.core.CryptoService +import com.aamdigital.aambackendservice.domain.EntityAttribute +import com.aamdigital.aambackendservice.domain.EntityConfig +import com.aamdigital.aambackendservice.domain.EntityType +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import reactor.core.publisher.Mono +import java.security.MessageDigest + +data class AppConfigAttribute( + val dataType: String, +) + +data class AppConfigEntry( + val label: String?, + val attributes: Map?, +) + +data class AppConfigFile( + @JsonProperty("_id") + val id: String, + @JsonProperty("_rev") + val rev: String, + val data: Map?, +) + +data class TableFields( + val fields: Map +) + +data class TableName( + val operation: String = "prefix", + val field: String, + val separator: String, +) + +data class SqlObject( + val tables: Map, + val indexes: List, + val options: SqlOptions, +) + +data class SqlOptions( + @JsonProperty("table_name") + val tableName: TableName +) + +data class SqsSchema( + val language: String = "sqlite", + val sql: SqlObject, +) { + var configVersion: String + + init { + configVersion = generateConfigVersion() + } + + @OptIn(ExperimentalStdlibApi::class) + fun generateConfigVersion(): String { + val md = MessageDigest.getInstance("SHA-256") + val input = jacksonObjectMapper().writeValueAsString(sql).toByteArray() + val bytes = md.digest(input) + return bytes.toHexString() + } +} + +@Service +class SqsSchemaService( + private val couchDbStorage: CouchDbStorage, + private val cryptoService: CryptoService, +) { + + companion object { + private const val FILENAME_CONFIG_ENTITY = "Config:CONFIG_ENTITY"; + private const val SCHEMA_PATH = "_design/sqlite:config" + private const val TARGET_DATABASE = "app" + } + + + fun getSchemaPath(): String = "/$TARGET_DATABASE/$SCHEMA_PATH" + + fun updateSchema(): Mono { + return Mono.zip( + couchDbStorage.getDatabaseDocument( + database = TARGET_DATABASE, + documentId = FILENAME_CONFIG_ENTITY, + queryParams = LinkedMultiValueMap(), + kClass = AppConfigFile::class + ) + .map { config -> + val entities: List = config.data.orEmpty().keys + .filter { + it.startsWith("entity:") + } + .map { + val entityType: AppConfigEntry = config.data.orEmpty().getValue(it) + parseEntityConfig(it, entityType) + } + + EntityConfig(config.rev, entities) + }, + couchDbStorage.getDatabaseDocument( + database = TARGET_DATABASE, + documentId = SCHEMA_PATH, + queryParams = LinkedMultiValueMap(), + kClass = SqsSchema::class + ) + ) + .flatMap { + val entityConfig = it.t1 + val currentSqsSchema = it.t2 + val newSqsSchema: SqsSchema = mapToSqsSchema(entityConfig) + + if (currentSqsSchema.configVersion == newSqsSchema.configVersion) { + return@flatMap Mono.just(Unit) + } + + couchDbStorage.putDatabaseDocument( + database = TARGET_DATABASE, + documentId = SCHEMA_PATH, + body = newSqsSchema, + ) + .flatMap { + Mono.just(Unit) + } + } + } + + private fun mapToSqsSchema(entityConfig: EntityConfig): SqsSchema { + val tables = entityConfig.entities.map { entityType -> + val attributes = entityType.attributes + .filter { + it.type != "file" + } + .map { + EntityAttribute( + it.name, + mapConfigDataTypeToSqsDataType(it.type) + ) + } + .plus(getDefaultEntityAttributes()) + + Pair(entityType.label, attributes.associate { + Pair(it.name, it.type) + }) + }.associate { + Pair(it.first, TableFields(it.second)) + } + + return SqsSchema( + sql = SqlObject( + tables = tables, + options = SqlOptions( + TableName( + field = "_id", + separator = ":" + ) + ), + indexes = emptyList(), + ) + ) + } + + private fun mapConfigDataTypeToSqsDataType(dataType: String): String = when (dataType) { + "number", + "integer", + "boolean" -> { + "INTEGER" + } + + else -> "TEXT" + } + + private fun getDefaultEntityAttributes(): List = listOf( + EntityAttribute("_id", "TEXT"), + EntityAttribute("_rev", "TEXT"), + EntityAttribute("created", "TEXT"), + EntityAttribute("updated", "TEXT"), + EntityAttribute("inactive", "INTEGER"), + EntityAttribute("anonymized", "INTEGER"), + ) + + + private fun parseEntityConfig(entityKey: String, config: AppConfigEntry): EntityType { + return EntityType( + label = config.label ?: entityKey.split(":")[1], + attributes = config.attributes.orEmpty().map { + EntityAttribute( + name = it.key, + type = it.value.dataType + ) + } + ) + } + +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/storage/DefaultReportingStorage.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/storage/DefaultReportingStorage.kt new file mode 100644 index 0000000..805fcc8 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/storage/DefaultReportingStorage.kt @@ -0,0 +1,150 @@ +package com.aamdigital.aambackendservice.reporting.report.storage + +import com.aamdigital.aambackendservice.couchdb.dto.CouchDbChange +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.NotFoundException +import com.aamdigital.aambackendservice.reporting.domain.Report +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculationStatus +import com.aamdigital.aambackendservice.reporting.domain.ReportData +import com.aamdigital.aambackendservice.reporting.domain.ReportSchema +import com.aamdigital.aambackendservice.reporting.report.core.ReportSchemaGenerator +import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage +import com.fasterxml.jackson.annotation.JsonProperty +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import reactor.core.publisher.Mono +import java.util.* + +data class ReportCalculationEntity( + val id: String, + val key: String, + val value: CouchDbChange, + val doc: ReportCalculation, +) + +data class FetchReportCalculationsResponse( + @JsonProperty("total_rows") + val totalRows: Int, + val offset: Int, + val rows: List, +) + +@Service +class DefaultReportingStorage( + private val reportRepository: ReportRepository, + private val reportCalculationRepository: ReportCalculationRepository, + private val reportSchemaGenerator: ReportSchemaGenerator, +) : ReportingStorage { + override fun fetchAllReports(mode: String): Mono> { + return reportRepository.fetchReports() + .map { response -> + if (response.rows.isEmpty()) { + return@map emptyList() + } + + response.rows.filter { + it.doc.mode == mode + }.map { + Report( + id = it.id, + name = it.doc.title, + mode = it.doc.mode, + query = it.doc.aggregationDefinition, + schema = ReportSchema( + fields = reportSchemaGenerator.getTableNamesByQuery(it.doc.aggregationDefinition) + ) + ) + } + } + } + + override fun fetchReport(report: DomainReference): Mono> { + return reportRepository.fetchReport( + documentId = report.id, + queryParams = LinkedMultiValueMap(), + ).map { reportDoc -> + Optional.of( + Report( + id = reportDoc.id, + name = reportDoc.title, + query = reportDoc.aggregationDefinition, + mode = reportDoc.mode, + schema = ReportSchema( + fields = reportSchemaGenerator.getTableNamesByQuery(reportDoc.aggregationDefinition) + ) + ) + ) + } + .onErrorReturn(Optional.empty()) + } + + override fun fetchPendingCalculations(): Mono> { + return reportCalculationRepository.fetchCalculations() + .map { response -> + response.rows + .filter { reportCalculationEntity -> + reportCalculationEntity.doc.status == ReportCalculationStatus.PENDING + } + .map { reportCalculationEntity -> mapFromEntity(reportCalculationEntity) } + } + } + + override fun fetchCalculations(reportReference: DomainReference): Mono> { + return reportCalculationRepository.fetchCalculations() + .map { response -> + response.rows + .filter { entity -> + entity.doc.report.id == reportReference.id + } + .map { entity -> + mapFromEntity(entity) + } + } + } + + override fun fetchCalculation(calculationReference: DomainReference): Mono> { + return reportCalculationRepository.fetchCalculation(calculationReference) + } + + override fun storeCalculation(reportCalculation: ReportCalculation): Mono { + return reportCalculationRepository.storeCalculation(reportCalculation = reportCalculation) + .flatMap { entity -> + fetchCalculation(DomainReference(entity.id)) + } + .map { + it.orElseThrow { NotFoundException() } + } + } + + override fun storeData(reportData: ReportData): Mono { + return reportCalculationRepository.storeData(reportData) + } + + override fun fetchData(calculationReference: DomainReference): Mono> { + return reportCalculationRepository.fetchData(calculationReference) + } + + override fun isCalculationOngoing(reportReference: DomainReference): Mono { + return reportCalculationRepository.fetchCalculations() + .map { response -> + response.rows + .filter { reportCalculation -> + reportCalculation.doc.report.id == reportReference.id + }.any { reportCalculation -> + reportCalculation.doc.status == ReportCalculationStatus.PENDING || + reportCalculation.doc.status == ReportCalculationStatus.RUNNING + + } + } + } + + private fun mapFromEntity(entity: ReportCalculationEntity): ReportCalculation = ReportCalculation( + id = entity.doc.id, + report = entity.doc.report, + status = entity.doc.status, + startDate = entity.doc.startDate, + endDate = entity.doc.endDate, + outcome = entity.doc.outcome + ) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/storage/ReportCalculationRepository.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/storage/ReportCalculationRepository.kt new file mode 100644 index 0000000..22bc7c9 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/storage/ReportCalculationRepository.kt @@ -0,0 +1,113 @@ +package com.aamdigital.aambackendservice.reporting.report.storage + +import com.aamdigital.aambackendservice.couchdb.core.CouchDbStorage +import com.aamdigital.aambackendservice.couchdb.core.getQueryParamsAllDocs +import com.aamdigital.aambackendservice.couchdb.dto.DocSuccess +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.NotFoundException +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculationOutcome +import com.aamdigital.aambackendservice.reporting.domain.ReportData +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import reactor.core.publisher.Mono +import java.util.* + +@Service +class ReportCalculationRepository( + private val couchDbStorage: CouchDbStorage, +) { + companion object { + private const val REPORT_CALCULATION_DATABASE = "report-calculation" + } + + fun storeCalculation( + reportCalculation: ReportCalculation, + ): Mono { + return couchDbStorage.putDatabaseDocument( + database = REPORT_CALCULATION_DATABASE, + documentId = reportCalculation.id, + body = reportCalculation, + ) + } + + fun fetchCalculations(): Mono { + return couchDbStorage.getDatabaseDocument( + database = REPORT_CALCULATION_DATABASE, + documentId = "_all_docs", + getQueryParamsAllDocs("ReportCalculation"), + FetchReportCalculationsResponse::class + ) + } + + fun fetchCalculation(calculationReference: DomainReference): Mono> { + return couchDbStorage.getDatabaseDocument( + database = REPORT_CALCULATION_DATABASE, + documentId = calculationReference.id, + queryParams = LinkedMultiValueMap(), + kClass = ReportCalculation::class + ) + .map { Optional.of(it) } + .defaultIfEmpty(Optional.empty()) + .onErrorReturn(Optional.empty()) + } + + fun storeData(data: ReportData): Mono { + return couchDbStorage.putDatabaseDocument( + database = REPORT_CALCULATION_DATABASE, + documentId = data.id, + body = data, + ) + .flatMap { fetchCalculation(data.calculation) } + .flatMap { + val calculation = it.orElseThrow { + NotFoundException() + } + + calculation.outcome = ReportCalculationOutcome.Success( + resultHash = data.getDataHash() + ) + + couchDbStorage.putDatabaseDocument( + database = REPORT_CALCULATION_DATABASE, + documentId = calculation.id, + body = calculation + ) + }.map { data } + } + + fun fetchData(calculationReference: DomainReference): Mono> { + return fetchCalculation(calculationReference) + .map { + val calculation = it.orElseThrow { + NotFoundException() + } + calculation.id + } + .flatMap { calculationId -> + couchDbStorage.find( + database = "report-calculation", + body = mapOf( + Pair( + "selector", mapOf( + Pair( + "calculation.id", mapOf( + Pair("\$eq", calculationId) + ) + ) + ) + ) + ), + queryParams = LinkedMultiValueMap(), + kClass = ReportData::class + ) + } + .map { + if (it.docs.size == 1) { + Optional.of(it.docs.first()) + } else { + Optional.empty() + } + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/storage/ReportRepository.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/storage/ReportRepository.kt new file mode 100644 index 0000000..2c15b62 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/storage/ReportRepository.kt @@ -0,0 +1,36 @@ +package com.aamdigital.aambackendservice.reporting.report.storage + +import com.aamdigital.aambackendservice.couchdb.core.CouchDbStorage +import com.aamdigital.aambackendservice.couchdb.core.getQueryParamsAllDocs +import com.aamdigital.aambackendservice.reporting.report.dto.FetchReportsResponse +import com.aamdigital.aambackendservice.reporting.report.dto.ReportDoc +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import reactor.core.publisher.Mono + +@Service +class ReportRepository( + private val couchDbStorage: CouchDbStorage, +) { + companion object { + private const val REPORT_DATABASE = "app" + } + + fun fetchReports(): Mono { + return couchDbStorage.getDatabaseDocument( + database = REPORT_DATABASE, + documentId = "_all_docs", + getQueryParamsAllDocs("ReportConfig"), + FetchReportsResponse::class + ) + } + + fun fetchReport(documentId: String, queryParams: LinkedMultiValueMap): Mono { + return couchDbStorage.getDatabaseDocument( + database = REPORT_DATABASE, + documentId = documentId, + queryParams, + ReportDoc::class + ) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/controller/ReportCalculationController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/controller/ReportCalculationController.kt new file mode 100644 index 0000000..dba903c --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/controller/ReportCalculationController.kt @@ -0,0 +1,92 @@ +package com.aamdigital.aambackendservice.reporting.reportcalculation.controller + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.InternalServerException +import com.aamdigital.aambackendservice.error.NotFoundException +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation +import com.aamdigital.aambackendservice.reporting.domain.ReportData +import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationRequest +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationResult +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationUseCase +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono + +@RestController +@RequestMapping("/v1/reporting") +@Validated +class ReportCalculationController( + private val reportingStorage: ReportingStorage, + private val createReportCalculationUseCase: CreateReportCalculationUseCase +) { + @PostMapping("/report-calculation/report/{reportId}") + fun startCalculation( + @PathVariable reportId: String + ): Mono { + return reportingStorage.fetchReport(DomainReference(id = reportId)) + .flatMap { reportOptional -> + val report = reportOptional.orElseThrow { + NotFoundException() + } + + createReportCalculationUseCase.startReportCalculation( + CreateReportCalculationRequest( + report = DomainReference(report.id) + ) + ).handle { result, sink -> + when (result) { + is CreateReportCalculationResult.Failure -> { + sink.error(InternalServerException()) + } + + is CreateReportCalculationResult.Success -> sink.next(result.calculation) + } + } + } + } + + @GetMapping("/report-calculation/report/{reportId}") + fun fetchReportCalculations( + @PathVariable reportId: String + ): Mono> { + return reportingStorage.fetchCalculations(DomainReference(id = reportId)) + } + + @GetMapping("/report-calculation/{calculationId}") + fun fetchReportCalculation( + @PathVariable calculationId: String + ): Mono { + return reportingStorage.fetchCalculation(DomainReference(id = calculationId)) + .map { calculationOptional -> + val calculation = calculationOptional.orElseThrow { + NotFoundException() + } + + // TODO Auth check + + calculation + } + } + + @GetMapping("/report-calculation/{calculationId}/data") + fun fetchReportCalculationData( + @PathVariable calculationId: String + ): Mono { + return reportingStorage.fetchData(DomainReference(id = calculationId)) + .map { calculationOptional -> + val calculation = calculationOptional.orElseThrow { + NotFoundException() + } + + // TODO Auth check + + calculation + } + } + +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/controller/ReportController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/controller/ReportController.kt new file mode 100644 index 0000000..7ef3015 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/controller/ReportController.kt @@ -0,0 +1,67 @@ +package com.aamdigital.aambackendservice.reporting.reportcalculation.controller + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.NotFoundException +import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage +import com.aamdigital.aambackendservice.reporting.report.dto.ReportDto +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono + +@RestController +@RequestMapping("/v1/reporting") +@Validated +class ReportController( + private val reportingStorage: ReportingStorage, +) { + @GetMapping("/report") + fun fetchReports(): Mono> { + return reportingStorage.fetchAllReports("sql") + .zipWith( + reportingStorage.fetchPendingCalculations() + ).map { results -> + val reports = results.t1 + val calculations = results.t2 + reports.map { report -> + ReportDto( + id = report.id, + name = report.name, + schema = report.schema, + calculationPending = calculations.any { + it.id == report.id + } + ) + } + } + } + + @GetMapping("/report/{reportId}") + fun fetchReport( + @PathVariable reportId: String + ): Mono { + return reportingStorage + .fetchReport(DomainReference(id = reportId)) + .zipWith( + reportingStorage.fetchPendingCalculations() + ).map { results -> + val reportOptional = results.t1 + val calculations = results.t2 + + val report = reportOptional.orElseThrow { + NotFoundException() + } + + ReportDto( + id = report.id, + name = report.name, + schema = report.schema, + calculationPending = calculations.any { + it.id == report.id + } + ) + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/CreateReportCalculationUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/CreateReportCalculationUseCase.kt new file mode 100644 index 0000000..9168395 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/CreateReportCalculationUseCase.kt @@ -0,0 +1,35 @@ +package com.aamdigital.aambackendservice.reporting.reportcalculation.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import reactor.core.publisher.Mono + +data class CreateReportCalculationRequest( + val report: DomainReference, +) + +@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes( + JsonSubTypes.Type(value = CreateReportCalculationResult.Success::class), + JsonSubTypes.Type(value = CreateReportCalculationResult.Failure::class), +) +sealed class CreateReportCalculationResult { + data class Success( + val calculation: DomainReference, + ) : CreateReportCalculationResult() + + data class Failure( + val errorCode: ErrorCode, + val errorMessage: String = "", + val cause: Throwable? = null + ) : CreateReportCalculationResult() + + enum class ErrorCode { + INTERNAL_SERVER_ERROR + } +} + +interface CreateReportCalculationUseCase { + fun startReportCalculation(request: CreateReportCalculationRequest): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultCreateReportCalculationUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultCreateReportCalculationUseCase.kt new file mode 100644 index 0000000..e5a0db1 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultCreateReportCalculationUseCase.kt @@ -0,0 +1,45 @@ +package com.aamdigital.aambackendservice.reporting.reportcalculation.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculationStatus +import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono +import java.util.* + +@Service +class DefaultCreateReportCalculationUseCase( + private val reportingStorage: ReportingStorage, +) : CreateReportCalculationUseCase { + override fun startReportCalculation(request: CreateReportCalculationRequest): Mono { + val calculation = ReportCalculation( + id = "ReportCalculation:${UUID.randomUUID()}", + report = request.report, + status = ReportCalculationStatus.PENDING, + ) + + return reportingStorage.storeCalculation(calculation) + .map { + handleResponse(it) + } + .onErrorResume { + handleError(it) + } + } + + private fun handleResponse(reportCalculation: ReportCalculation): CreateReportCalculationResult { + return CreateReportCalculationResult.Success( + DomainReference(id = reportCalculation.id) + ) + } + + private fun handleError(it: Throwable): Mono { + return Mono.just( + CreateReportCalculationResult.Failure( + errorCode = CreateReportCalculationResult.ErrorCode.INTERNAL_SERVER_ERROR, + cause = it + ) + ) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculationChangeUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculationChangeUseCase.kt new file mode 100644 index 0000000..4eb28b5 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculationChangeUseCase.kt @@ -0,0 +1,54 @@ +package com.aamdigital.aambackendservice.reporting.reportcalculation.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculationStatus +import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent +import com.aamdigital.aambackendservice.reporting.notification.core.NotificationService +import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage +import com.fasterxml.jackson.databind.ObjectMapper +import org.slf4j.LoggerFactory +import reactor.core.publisher.Mono + +class DefaultReportCalculationChangeUseCase( + private val reportingStorage: ReportingStorage, + private val objectMapper: ObjectMapper, + private val notificationService: NotificationService, +) : ReportCalculationChangeUseCase { + + private val logger = LoggerFactory.getLogger(javaClass) + + override fun handle(documentChangeEvent: DocumentChangeEvent): Mono { + + val currentReportCalculation = + objectMapper.convertValue(documentChangeEvent.currentVersion, ReportCalculation::class.java) + + if (currentReportCalculation.status != ReportCalculationStatus.FINISHED_SUCCESS) { + return Mono.just(Unit) + } + + return reportingStorage.fetchCalculations( + reportReference = currentReportCalculation.report + ) + .map { calculations -> + calculations + .filter { it.id != currentReportCalculation.id } + .sortedBy { it.endDate } + } + .flatMap { + if (it.isEmpty() || it.last().outcome != currentReportCalculation.outcome) { + notificationService.sendNotifications( + report = currentReportCalculation.report, + reportCalculation = DomainReference(currentReportCalculation.id) + ) + } else { + logger.debug("skipped notification for ${currentReportCalculation.id}") + Mono.just(Unit) + } + } + .onErrorResume { + logger.warn("Could not find ${currentReportCalculation.id}. Skipped.") + Mono.just(Unit) + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculator.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculator.kt new file mode 100644 index 0000000..6c58651 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/DefaultReportCalculator.kt @@ -0,0 +1,44 @@ +package com.aamdigital.aambackendservice.reporting.reportcalculation.core + +import com.aamdigital.aambackendservice.domain.DomainReference +import com.aamdigital.aambackendservice.error.InvalidArgumentException +import com.aamdigital.aambackendservice.error.NotFoundException +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation +import com.aamdigital.aambackendservice.reporting.domain.ReportData +import com.aamdigital.aambackendservice.reporting.report.core.QueryStorage +import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage +import com.aamdigital.aambackendservice.reporting.report.sqs.QueryRequest +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono +import java.util.* +import kotlin.jvm.optionals.getOrDefault + +@Service +class DefaultReportCalculator( + private val reportingStorage: ReportingStorage, + private val queryStorage: QueryStorage, +) : ReportCalculator { + override fun calculate(reportCalculation: ReportCalculation): Mono { + return reportingStorage.fetchReport(reportCalculation.report) + .flatMap { reportOptional -> + val report = reportOptional.getOrDefault(null) + ?: return@flatMap Mono.error(NotFoundException()) + + if (report.mode != "sql") { + return@flatMap Mono.error(InvalidArgumentException()) + } + + queryStorage.executeQuery( + query = QueryRequest(query = report.query) + ) + .map { queryResult -> + ReportData( + id = "ReportData:${UUID.randomUUID()}", + report = reportCalculation.report, + calculation = DomainReference(reportCalculation.id), + data = queryResult.result + ) + } + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculationChangeUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculationChangeUseCase.kt new file mode 100644 index 0000000..240cd6d --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculationChangeUseCase.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.reporting.reportcalculation.core + +import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent +import reactor.core.publisher.Mono + +interface ReportCalculationChangeUseCase { + fun handle(documentChangeEvent: DocumentChangeEvent): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculator.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculator.kt new file mode 100644 index 0000000..641cf2b --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculator.kt @@ -0,0 +1,9 @@ +package com.aamdigital.aambackendservice.reporting.reportcalculation.core + +import com.aamdigital.aambackendservice.reporting.domain.ReportCalculation +import com.aamdigital.aambackendservice.reporting.domain.ReportData +import reactor.core.publisher.Mono + +interface ReportCalculator { + fun calculate(reportCalculation: ReportCalculation): Mono +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/di/ReportCalculationConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/di/ReportCalculationConfiguration.kt new file mode 100644 index 0000000..798e283 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/di/ReportCalculationConfiguration.kt @@ -0,0 +1,21 @@ +package com.aamdigital.aambackendservice.reporting.reportcalculation.di + +import com.aamdigital.aambackendservice.reporting.notification.core.NotificationService +import com.aamdigital.aambackendservice.reporting.report.core.ReportingStorage +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.DefaultReportCalculationChangeUseCase +import com.aamdigital.aambackendservice.reporting.reportcalculation.core.ReportCalculationChangeUseCase +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class ReportCalculationConfiguration { + + @Bean + fun defaultReportCalculationChangeUseCase( + reportingStorage: ReportingStorage, + objectMapper: ObjectMapper, + notificationService: NotificationService, + ): ReportCalculationChangeUseCase = + DefaultReportCalculationChangeUseCase(reportingStorage, objectMapper, notificationService) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/rest/AamErrorAttributes.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/rest/AamErrorAttributes.kt new file mode 100644 index 0000000..6eae691 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/rest/AamErrorAttributes.kt @@ -0,0 +1,77 @@ +package com.aamdigital.aambackendservice.rest + +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.error.ExternalSystemException +import com.aamdigital.aambackendservice.error.ForbiddenAccessException +import com.aamdigital.aambackendservice.error.InternalServerException +import com.aamdigital.aambackendservice.error.InvalidArgumentException +import com.aamdigital.aambackendservice.error.NotFoundException +import com.aamdigital.aambackendservice.error.UnauthorizedAccessException +import org.springframework.boot.web.error.ErrorAttributeOptions +import org.springframework.boot.web.reactive.error.DefaultErrorAttributes +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.validation.FieldError +import org.springframework.web.bind.support.WebExchangeBindException +import org.springframework.web.reactive.function.server.ServerRequest +import org.springframework.web.reactive.resource.NoResourceFoundException + +@Component +class AamErrorAttributes : DefaultErrorAttributes() { + + override fun getErrorAttributes( + request: ServerRequest, + options: ErrorAttributeOptions + ): MutableMap { + val errorAttributes = super.getErrorAttributes(request, options) + + when (val error = getError(request)) { + is AamException -> { + errorAttributes["status"] = getStatus(error) + errorAttributes["error"] = error.code + errorAttributes["message"] = error.message + } + + is WebExchangeBindException -> { + errorAttributes["status"] = HttpStatus.BAD_REQUEST.value() + errorAttributes["error"] = "BAD_REQUEST" + errorAttributes["message"] = createErrorMessage(error) + } + + is NoResourceFoundException -> { + errorAttributes["status"] = HttpStatus.NOT_FOUND.value() + errorAttributes["error"] = "NOT_FOUND" + errorAttributes["message"] = "Not the place you're looking for." + } + } + + errorAttributes.remove("path") + errorAttributes.remove("requestId") + errorAttributes.remove("trace") + + return errorAttributes + } + + private fun createErrorMessage(error: WebExchangeBindException): String { + return if (error.hasFieldErrors()) { + error.allErrors.joinToString(", ") { + if (it is FieldError) { + "Error in field ${it.field}: ${it.defaultMessage}" + } else { + it.defaultMessage.toString() + } + } + } else { + error.reason ?: "" + } + } + + private fun getStatus(error: AamException) = when (error) { + is InternalServerException -> HttpStatus.INTERNAL_SERVER_ERROR.value() + is ExternalSystemException -> HttpStatus.INTERNAL_SERVER_ERROR.value() + is InvalidArgumentException -> HttpStatus.BAD_REQUEST.value() + is UnauthorizedAccessException -> HttpStatus.UNAUTHORIZED.value() + is ForbiddenAccessException -> HttpStatus.FORBIDDEN.value() + is NotFoundException -> HttpStatus.NOT_FOUND.value() + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt new file mode 100644 index 0000000..1465863 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt @@ -0,0 +1,61 @@ +package com.aamdigital.aambackendservice.security + +import com.aamdigital.aambackendservice.error.ForbiddenAccessException +import com.aamdigital.aambackendservice.error.UnauthorizedAccessException +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.security.web.server.ServerAuthenticationEntryPoint +import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono + +@Configuration +@EnableWebFluxSecurity +@EnableReactiveMethodSecurity +class SecurityConfiguration { + + @Bean + fun securityWebFilterChain( + http: ServerHttpSecurity, + ): SecurityWebFilterChain { + return http + .authorizeExchange { + it.pathMatchers(HttpMethod.GET, "/").permitAll() + it.pathMatchers(HttpMethod.GET, "/actuator").permitAll() + it.pathMatchers(HttpMethod.GET, "/actuator/**").permitAll() + it.anyExchange().authenticated() + } + .exceptionHandling { + it.accessDeniedHandler(customServerAccessDeniedHandler()) + it.authenticationEntryPoint(CustomAuthenticationEntryPoint()) + } + .oauth2ResourceServer { + it.jwt {} + } + .build() + } + + private fun customServerAccessDeniedHandler(): ServerAccessDeniedHandler { + return ServerAccessDeniedHandler { _, denied -> + throw ForbiddenAccessException( + message = "Access Token not sufficient for operation", + cause = denied + ) + } + } + + private class CustomAuthenticationEntryPoint : ServerAuthenticationEntryPoint { + override fun commence(exchange: ServerWebExchange, ex: AuthenticationException): Mono { + return Mono.fromRunnable { + throw UnauthorizedAccessException("Access Token invalid or missing") + } + } + } + +} diff --git a/application/aam-backend-service/src/main/resources/application.yaml b/application/aam-backend-service/src/main/resources/application.yaml new file mode 100644 index 0000000..fbec386 --- /dev/null +++ b/application/aam-backend-service/src/main/resources/application.yaml @@ -0,0 +1,77 @@ +spring: + application: + name: aam-backend-service + main: + banner-mode: off + jackson: + deserialization: + accept-empty-string-as-null-object: true + webflux: + base-path: /api + r2dbc: + url: r2dbc:h2:file://././data/dbh2;DB_CLOSE_DELAY=-1 + username: local + password: local + +management: + endpoint: + health: + probes: + enabled: true + endpoints: + web: + exposure: + include: + - info + - health + +--- + +spring: + config: + activate: + on-profile: local-development + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:8080/realms/dummy-realm + rabbitmq: + virtual-host: / + listener: + direct: + retry: + enabled: true + max-attempts: 5 + +server: + error: + include-message: always + include-binding-errors: always + port: 9000 +logging: + level: + # org.springframework.amqp.rabbit: warn + # org.springframework.http.client: debug + # reactor.netty.http.client: debug + com.aamdigital.aambackendservice: trace + +couch-db-client-configuration: + base-path: http://localhost:5984 + basic-auth-username: admin + basic-auth-password: docker + +sqs-client-configuration: + base-path: http://localhost:4984 + basic-auth-username: admin + basic-auth-password: docker + +database-change-detection: + enabled: true + +report-calculation-processor: + enabled: true + +sentry: + logging: + enabled: false diff --git a/application/aam-backend-service/src/main/resources/sql/embedded_h2_database_init_script.sql b/application/aam-backend-service/src/main/resources/sql/embedded_h2_database_init_script.sql new file mode 100644 index 0000000..5a27fc8 --- /dev/null +++ b/application/aam-backend-service/src/main/resources/sql/embedded_h2_database_init_script.sql @@ -0,0 +1,9 @@ +-- ----------------------------------------------------------------------------------- -- +-- Will create the SYNC_ENTITY Table, needed for internal state handling and caching -- +-- ----------------------------------------------------------------------------------- -- +CREATE TABLE IF NOT EXISTS SYNC_ENTRY +( + ID SERIAL PRIMARY KEY, + DATABASE VARCHAR(255), + LATEST_REF VARCHAR(255) +); diff --git a/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/ApplicationTests.kt b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/ApplicationTests.kt new file mode 100644 index 0000000..2286c28 --- /dev/null +++ b/application/aam-backend-service/src/test/kotlin/com/aamdigital/aambackendservice/ApplicationTests.kt @@ -0,0 +1,14 @@ +package com.aamdigital.aambackendservice + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class ApplicationTests { + + @Test + fun contextLoads() { + } + +} + diff --git a/docs/api-specs/reporting-api-v1.yaml b/docs/api-specs/reporting-api-v1.yaml new file mode 100644 index 0000000..5173448 --- /dev/null +++ b/docs/api-specs/reporting-api-v1.yaml @@ -0,0 +1,566 @@ +openapi: 3.0.3 +info: + title: Aam Digital - Reporting API + description: |- + API to manage reports that provide data calculated based on any entities of the Aam Digital system + and offer notifications when data of such reports changes. + version: 1.0.0-alpha.2 +servers: + - url: https://{customerId}.aam-digital.net/api/v1/reporting + description: Developer Instance for testing + variables: + customerId: + default: dev + description: Customer ID assigned by the service provider +tags: + - name: reporting + description: Access reports and their results and trigger one-time report calculations. + - name: notifications + description: Subscribe to continuous notification events whenever a report's result data changes due to users changing records in the Aam Digital application. + +paths: + /report: + get: + security: + - development: + - reporting_read + tags: + - reporting + summary: Return list of available Reports + responses: + 200: + description: List of all available Reports the requester has access permissions to, empty array if no reports are available + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Report' + 401: + description: If no valid access token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /report/{reportId}: + get: + security: + - development: + - reporting_read + tags: + - reporting + summary: Return report metadata by ID + parameters: + - in: path + name: reportId + schema: + type: string + required: true + responses: + 200: + description: Get details of a report, including details of the data structure (schema) this specific report's data has + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + 404: + description: If the Report does not exist + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + 401: + description: If the access token does not grant permission to this Report + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /report-calculation/report/{reportId}: + get: + security: + - development: + - reporting_read + tags: + - reporting + summary: Return all report calculations for a report + parameters: + - in: path + name: reportId + schema: + type: string + required: true + responses: + 200: + description: List of metadata of all calculations triggered by any user (pending and completed) + content: + application/json: + schema: + $ref: '#/components/schemas/ReportCalculation' + 401: + description: If the access token does not grant permission to this Report + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + 404: + description: If report does not exist + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + post: + security: + - development: + - reporting_write + tags: + - reporting + summary: Trigger a new report calculation run. + description: Trigger a new report calculation run. Check status of the asynchronous calculation via the /report-calculation endpoint + As an alternative to triggering a single report calculation, you can subscribe to receive + a notification event (which includes an automatically created calculationId) whenever the report's + results change (see /webhook/{webhookId}/subscribe/report). + parameters: + - in: path + name: reportId + schema: + type: string + required: true + - in: query + name: from + description: start date for report period + schema: + type: string + format: date + - in: query + name: to + description: end date for report period + schema: + type: string + format: date + responses: + 200: + description: Return calculation identifier, to be used to check status and result data + content: + application/json: + schema: + type: object + properties: + id: + type: string + format: uuid + 401: + description: If the access token does not grant permission to this Report + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /report-calculation/{calculationId}: + get: + security: + - development: + - reporting_read + tags: + - reporting + summary: Return metadata for a report calculation + parameters: + - in: path + name: calculationId + schema: + type: string + required: true + responses: + 200: + description: Status and details of the given report calculation run + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ReportCalculation' + 401: + description: If the access token does not grant permission to this Report + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + 404: + description: If the calculation identifier does not exist + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /report-calculation/{calculationId}/data: + get: + security: + - development: + - reporting_read + tags: + - reporting + summary: Fetch actual report data for a specific calculation + parameters: + - in: path + name: calculationId + schema: + type: string + required: true + responses: + 200: + description: The actual data that has been calculated by the calculation run. This matches the schema defined in the /report/id + content: + application/json: + schema: + $ref: '#/components/schemas/ReportData' + application/xml: + schema: + $ref: '#/components/schemas/ReportData' + 404: + description: report data is not available yet (when either the calculation id is not valid or the calculation is still running) + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + 401: + description: If the access token does not grant permission to this Report + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + # notification + + /webhook: + get: + security: + - development: + - reporting_read + tags: + - notifications + summary: Return list of existing Webhooks for your authorized client + responses: + 200: + description: List of all available Webhooks the requester has created, empty array if no Webhooks are available for the client authorized in the given access token + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Webhook' + 401: + description: If no valid access token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + 403: + description: No permissions to access the resource + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + security: + - development: + - reporting_write + tags: + - notifications + summary: Subscribe to events for a specific Report + description: Register a webhook to be called for one or more notification subscriptions. To receive events, make calls to your webhookId's /subscribe endpoint. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookConfiguration' + responses: + 200: + description: Webhook registered successfully, you can now register subscriptions + content: + application/json: + schema: + type: object + properties: + id: + type: string + format: uuid + 401: + description: If no valid access token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /webhook/{webhookId}: + get: + security: + - development: + - reporting_read + tags: + - notifications + summary: Return a specific Webhook + parameters: + - in: path + name: webhookId + schema: + type: string + required: true + responses: + 200: + description: Get details of a webhook, including details of subscribed events and configuration + content: + application/json: + schema: + $ref: '#/components/schemas/Webhook' + 401: + description: If no valid access token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + 403: + description: No permissions to access the resource + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + 404: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /webhook/{webhookId}/subscribe/report/{reportId}: + post: + security: + - development: + - reporting_write + tags: + - notifications + summary: Subscribe to events for a specific Report + description: If you subscribe to a reportId, Aam Digital will continuously monitor the changes users + make in the application and check if they affect the results calculated by that report. + Whenever this is the case, a new report-calculation is automatically triggered for you and once + the calculation is completed you receive an event containing that calculationId. You can use + this to immediately fetch to available report data from the /report-calculation/{calculationId}/data + endpoint. Upon subscribing to a report, you will always receive an initial event with a + calculationId through which you can access the current state of the report's data. + externalDocs: + url: https://github.com/Aam-Digital/query-backend/blob/main/README.md#subscribe-to-continuous-changes-of-a-report + parameters: + - in: path + name: webhookId + schema: + type: string + required: true + - in: path + name: reportId + schema: + type: string + required: true + responses: + 200: + description: Report added to webhook subscription + 401: + description: If no valid access token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + 403: + description: No permissions to access the resource + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + 404: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + delete: + security: + - development: + - reporting_write + tags: + - notifications + summary: Remove event subscription for a specific Report + parameters: + - in: path + name: webhookId + schema: + type: string + required: true + - in: path + name: eventName + schema: + type: string + required: true + responses: + 200: + description: Report deleted from webhook subscription + 401: + description: If no valid access token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + 403: + description: No permissions to access the resource + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + +components: + schemas: + # report + Report: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: report_all_course_members + calculationPending: + type: boolean + schema: + $ref: '#/components/schemas/ReportSchema' + + ReportSchema: + type: object + properties: + fields: + type: array + items: + type: object + + ReportCalculation: + type: object + properties: + id: + type: string + format: uuid + start_date: + type: string + example: date + end_date: + type: string + example: date + nullable: true + status: + type: string + description: Current status of the run + enum: + - PENDING + - RUNNING + - FINISHED_SUCCESS + - FINISHED_ERROR + + ReportData: + type: object + properties: + reportId: + type: string + format: uuid + data: + type: object + + # notification + + Webhook: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + example: wh_aam_digital_reporting + reportSubscriptions: + description: The reports for which the webhook will receive a notification event whenever their results change due to users managing data in the Aam Digital system. + type: array + items: + type: string + example: [ "report_id_1", "report_id_2", "report_id_3" ] + + WebhookConfiguration: + type: object + properties: + method: + type: string + enum: + - GET + - POST + targetUrl: + type: string + authenticationType: + type: string + enum: + - API_KEY + authentication: + type: object + oneOf: + - $ref: '#/components/schemas/ApiKeyAuthConfig' + + ApiKeyAuthConfig: + type: object + properties: + key: + type: string + headerName: + type: string + + Event: + type: object + description: Representation of the payload we will send to the webhook + properties: + id: + type: string + format: uuid + eventName: + type: string + enum: + - NEW_REPORT_DATA_AVAILABLE + reportId: + type: string + calculationId: + type: string + created_at: + type: string + format: date + + # global + + Error: + type: object + properties: + errorCode: + type: string + items: + enum: + - NOT_FOUND + - UNAUTHORIZED + - FORBIDDEN + - INTERNAL_SERVER_ERROR + errorMessage: + type: string + + securitySchemes: + development: + type: oauth2 + description: This API uses OAuth2 with the Client Credentials Flow + flows: + clientCredentials: + tokenUrl: https://keycloak.aam-digital.net/realms/TolaData/protocol/openid-connect/token + scopes: + reporting_read: Read Report and ReportCalculation + reporting_write: Trigger new ReportCalculation and configure webhook notifications diff --git a/docs/assets/keycloak-client-setup.png b/docs/assets/keycloak-client-setup.png new file mode 100644 index 0000000000000000000000000000000000000000..454bb59cb05150d7b43e7ea0f1a6f3936d3311a8 GIT binary patch literal 52877 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

gmt!skt*#pj^ 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 literal 0 HcmV?d00001 diff --git a/docs/modules/reporting.md b/docs/modules/reporting.md new file mode 100644 index 0000000..7d08042 --- /dev/null +++ b/docs/modules/reporting.md @@ -0,0 +1,58 @@ +# Aam Digital - Reporting / Query API + +An API to calculate "reports" (e.g. statistical, summarized indicators) based on entities in the primary database of an Aam Digital instance. + +This service allows to run SQL queries on the database. +In particular, this service allows users with limited permissions to see reports of aggregated statistics across all data (e.g. a supervisor could analyse reports without having access to possibly confidential details of participants or notes). + +----- + +## API access to reports + +Reports and their results are available for external services through the given API endpoints ([see OpenAPI specs](../api-specs/reporting-api-v1.yaml)). Endpoints require a valid JWT access token, which can be fetched via OAuth2 client credential flow. + +### Initial setup of an API integration + +1. Request client_id and client_secret from server administrator (--> admin has to [create new client grant in Keycloak](https://www.keycloak.org/docs/latest/server_admin/#_oidc_clients)) + ![Keycloak Client Setup](../assets/keycloak-client-setup.png) + +2. Get the realm of your instance (e.g. https://[your_realm].aam-digital.com). This is both the subdomain of systems hosted on aam-digital.com and the Keycloak Realm for authentication (case-sensitive!). + +### Access a reporting via API (after setup) + +1. Get valid access token using your client secret: + +```bash +curl -X "POST" "https://keycloak.aam-digital.net/realms//protocol/openid-connect/token" \ + -H 'Content-Type: application/x-www-form-urlencoded; charset=utf-8' \ + --data-urlencode "client_id=" \ + --data-urlencode "client_secret=" \ + --data-urlencode "grant_type=client_credentials" \ + --data-urlencode "scopes=reporting_read reporting_write" +``` + +Check API docs for the required "scopes". +This returns a JWT access token required to provided as Bearer Token for any request to the API endpoints. Sample token: + +```json +{ + "access_token": "eyJhbGciOiJSUzI...", + "expires_in": 300, + "refresh_expires_in": 0, + "token_type": "Bearer", + "not-before-policy": 0, + "scope": "reporting_read reporting_write" +} +``` + +2. Request the all available reports: `GET /v1/reporting/reports` (see OpenAPI specs for details) +3. Trigger the calculation of a reports data: `POST /v1/reporting/report-calculation/report/` +4. Get status of the report calculation: `GET /v1/reporting/report-calculation/` +5. Once the status shows the calculation is completed, get the actual result data: `GET /v1/reporting/report-calculation//data` + +### Subscribe to continuous changes of a report + +1. Create an initial webhook (if not already registered): `POST /v1/reporting/webhook` +2. Register for events of the selected report: `POST /v1/reporting/webhook/{webhookId}/subscribe/report/{reportId}` +3. You will receive Event objects to your webhook, including an initial event directly after you subscribe, pointing to the current report data +4. Use the report-calculation-id in the event to fetch actual data from `/v1/reporting/report-calculation/` endpoint