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 0000000..d64cd49 Binary files /dev/null and b/application/aam-backend-service/gradle/wrapper/gradle-wrapper.jar differ 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 0000000..454bb59 Binary files /dev/null and b/docs/assets/keycloak-client-setup.png differ 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