From 1cd0f2ea188a597859fd5470be936d758cab41c2 Mon Sep 17 00:00:00 2001 From: elinuxhenrik Date: Mon, 28 Mar 2022 09:48:52 +0200 Subject: [PATCH] Seed code Issue-ID: NONRTRIC-713 Signed-off-by: elinuxhenrik Change-Id: I858010c1440f3e7afdb77c3096b3a83027d9f689 --- .gitignore | 22 + .readthedocs.yaml | 16 + Dockerfile | 51 +++ README.md | 110 +++++ api/README.md | 0 api/api.json | 409 +++++++++++++++++ api/api.yaml | 482 +++++++++++++++++++++ config/README | 41 ++ config/application.yaml | 60 +++ config/application_configuration.json | 14 + config/keystore.jks | Bin 0 -> 4987 bytes config/truststore.jks | Bin 0 -> 3683 bytes docs/_static/logo.png | Bin 0 -> 43935 bytes docs/api-docs.rst | 34 ++ docs/conf.py | 24 + docs/conf.yaml | 3 + docs/developer-guide.rst | 32 ++ docs/favicon.ico | Bin 0 -> 15086 bytes docs/images/swagger.png | Bin 0 -> 3590 bytes docs/images/yaml_logo.png | Bin 0 -> 3477 bytes docs/index.rst | 17 + docs/overview.rst | 15 + docs/release-notes.rst | 70 +++ docs/requirements-docs.txt | 12 + eclipse-formatter.xml | 362 ++++++++++++++++ pom.xml | 364 ++++++++++++++++ .../java/org/oran/dmaapadapter/Application.java | 78 ++++ .../java/org/oran/dmaapadapter/BeanFactory.java | 70 +++ .../java/org/oran/dmaapadapter/SwaggerConfig.java | 43 ++ .../oran/dmaapadapter/clients/AsyncRestClient.java | 221 ++++++++++ .../clients/AsyncRestClientFactory.java | 193 +++++++++ .../configuration/ApplicationConfig.java | 139 ++++++ .../configuration/WebClientConfig.java | 54 +++ .../dmaapadapter/controllers/ErrorResponse.java | 112 +++++ .../controllers/ProducerCallbacksController.java | 148 +++++++ .../dmaapadapter/controllers/VoidResponse.java | 31 ++ .../dmaapadapter/exceptions/ServiceException.java | 39 ++ .../org/oran/dmaapadapter/r1/ConsumerJobInfo.java | 71 +++ .../oran/dmaapadapter/r1/ProducerInfoTypeInfo.java | 52 +++ .../org/oran/dmaapadapter/r1/ProducerJobInfo.java | 77 ++++ .../dmaapadapter/r1/ProducerRegistrationInfo.java | 64 +++ .../org/oran/dmaapadapter/repository/InfoType.java | 56 +++ .../oran/dmaapadapter/repository/InfoTypes.java | 80 ++++ .../java/org/oran/dmaapadapter/repository/Job.java | 127 ++++++ .../org/oran/dmaapadapter/repository/Jobs.java | 130 ++++++ .../org/oran/dmaapadapter/repository/MultiMap.java | 78 ++++ .../dmaapadapter/tasks/DmaapTopicConsumer.java | 107 +++++ .../dmaapadapter/tasks/DmaapTopicConsumers.java | 43 ++ .../dmaapadapter/tasks/KafkaJobDataConsumer.java | 142 ++++++ .../dmaapadapter/tasks/KafkaTopicConsumers.java | 120 +++++ .../dmaapadapter/tasks/KafkaTopicListener.java | 105 +++++ .../tasks/ProducerRegstrationTask.java | 192 ++++++++ src/main/resources/typeSchemaDmaap.json | 10 + src/main/resources/typeSchemaKafka.json | 33 ++ .../org/oran/dmaapadapter/ApplicationTest.java | 360 +++++++++++++++ .../org/oran/dmaapadapter/ConsumerController.java | 87 ++++ .../dmaapadapter/DmaapSimulatorController.java | 73 ++++ .../oran/dmaapadapter/IcsSimulatorController.java | 117 +++++ .../org/oran/dmaapadapter/IntegrationWithIcs.java | 273 ++++++++++++ .../oran/dmaapadapter/IntegrationWithKafka.java | 344 +++++++++++++++ .../resources/test_application_configuration.json | 14 + tox.ini | 37 ++ 62 files changed, 6058 insertions(+) create mode 100644 .gitignore create mode 100644 .readthedocs.yaml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 api/README.md create mode 100644 api/api.json create mode 100644 api/api.yaml create mode 100644 config/README create mode 100644 config/application.yaml create mode 100644 config/application_configuration.json create mode 100644 config/keystore.jks create mode 100644 config/truststore.jks create mode 100644 docs/_static/logo.png create mode 100644 docs/api-docs.rst create mode 100644 docs/conf.py create mode 100644 docs/conf.yaml create mode 100644 docs/developer-guide.rst create mode 100644 docs/favicon.ico create mode 100644 docs/images/swagger.png create mode 100644 docs/images/yaml_logo.png create mode 100644 docs/index.rst create mode 100644 docs/overview.rst create mode 100644 docs/release-notes.rst create mode 100644 docs/requirements-docs.txt create mode 100644 eclipse-formatter.xml create mode 100644 pom.xml create mode 100644 src/main/java/org/oran/dmaapadapter/Application.java create mode 100644 src/main/java/org/oran/dmaapadapter/BeanFactory.java create mode 100644 src/main/java/org/oran/dmaapadapter/SwaggerConfig.java create mode 100644 src/main/java/org/oran/dmaapadapter/clients/AsyncRestClient.java create mode 100644 src/main/java/org/oran/dmaapadapter/clients/AsyncRestClientFactory.java create mode 100644 src/main/java/org/oran/dmaapadapter/configuration/ApplicationConfig.java create mode 100644 src/main/java/org/oran/dmaapadapter/configuration/WebClientConfig.java create mode 100644 src/main/java/org/oran/dmaapadapter/controllers/ErrorResponse.java create mode 100644 src/main/java/org/oran/dmaapadapter/controllers/ProducerCallbacksController.java create mode 100644 src/main/java/org/oran/dmaapadapter/controllers/VoidResponse.java create mode 100644 src/main/java/org/oran/dmaapadapter/exceptions/ServiceException.java create mode 100644 src/main/java/org/oran/dmaapadapter/r1/ConsumerJobInfo.java create mode 100644 src/main/java/org/oran/dmaapadapter/r1/ProducerInfoTypeInfo.java create mode 100644 src/main/java/org/oran/dmaapadapter/r1/ProducerJobInfo.java create mode 100644 src/main/java/org/oran/dmaapadapter/r1/ProducerRegistrationInfo.java create mode 100644 src/main/java/org/oran/dmaapadapter/repository/InfoType.java create mode 100644 src/main/java/org/oran/dmaapadapter/repository/InfoTypes.java create mode 100644 src/main/java/org/oran/dmaapadapter/repository/Job.java create mode 100644 src/main/java/org/oran/dmaapadapter/repository/Jobs.java create mode 100644 src/main/java/org/oran/dmaapadapter/repository/MultiMap.java create mode 100644 src/main/java/org/oran/dmaapadapter/tasks/DmaapTopicConsumer.java create mode 100644 src/main/java/org/oran/dmaapadapter/tasks/DmaapTopicConsumers.java create mode 100644 src/main/java/org/oran/dmaapadapter/tasks/KafkaJobDataConsumer.java create mode 100644 src/main/java/org/oran/dmaapadapter/tasks/KafkaTopicConsumers.java create mode 100644 src/main/java/org/oran/dmaapadapter/tasks/KafkaTopicListener.java create mode 100644 src/main/java/org/oran/dmaapadapter/tasks/ProducerRegstrationTask.java create mode 100644 src/main/resources/typeSchemaDmaap.json create mode 100644 src/main/resources/typeSchemaKafka.json create mode 100644 src/test/java/org/oran/dmaapadapter/ApplicationTest.java create mode 100644 src/test/java/org/oran/dmaapadapter/ConsumerController.java create mode 100644 src/test/java/org/oran/dmaapadapter/DmaapSimulatorController.java create mode 100644 src/test/java/org/oran/dmaapadapter/IcsSimulatorController.java create mode 100644 src/test/java/org/oran/dmaapadapter/IntegrationWithIcs.java create mode 100644 src/test/java/org/oran/dmaapadapter/IntegrationWithKafka.java create mode 100644 src/test/resources/test_application_configuration.json create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5915080 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Documentation +.idea/ +.tox +docs/_build/ +.DS_STORE +.swagger* +docs/offeredapis/swagger/README.md + +# Eclipse +.checkstyle +.classpath +target/ +.sts4-cache +.project +.settings +.pydevproject +infer-out/ + +.vscode +.factorypath + +coverage.* diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..095222a --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +--- +version: 2 + +formats: + - htmlzip + +build: + image: latest + +python: + version: 3.7 + install: + - requirements: docs/requirements-docs.txt + +sphinx: + configuration: docs/conf.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ac70e36 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# +# ============LICENSE_START======================================================= +# O-RAN-SC +# ================================================================================ +# Copyright (C) 2021 Nordix Foundation. All rights reserved. +# ================================================================================ +# 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 +# +# http://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. +# +# SPDX-License-Identifier: Apache-2.0 +# ============LICENSE_END========================================================= + + +FROM openjdk:11-jre-slim + +EXPOSE 8084 8435 + +ARG JAR + +WORKDIR /opt/app/dmaap-adapter-service +RUN mkdir -p /var/log/dmaap-adapter-service +RUN mkdir -p /opt/app/dmaap-adapter-service/etc/cert/ +RUN mkdir -p /var/dmaap-adapter-service + +ADD /config/application.yaml /opt/app/dmaap-adapter-service/config/application.yaml +ADD /config/application_configuration.json /opt/app/dmaap-adapter-service/data/application_configuration.json_example +ADD /config/keystore.jks /opt/app/dmaap-adapter-service/etc/cert/keystore.jks +ADD /config/truststore.jks /opt/app/dmaap-adapter-service/etc/cert/truststore.jks + +ARG user=nonrtric +ARG group=nonrtric + +RUN groupadd $user && \ + useradd -r -g $group $user +RUN chown -R $user:$group /opt/app/dmaap-adapter-service +RUN chown -R $user:$group /var/log/dmaap-adapter-service +RUN chown -R $user:$group /var/dmaap-adapter-service + +USER ${user} + +ADD target/${JAR} /opt/app/dmaap-adapter-service/dmaap-adapter.jar +CMD ["java", "-jar", "/opt/app/dmaap-adapter-service/dmaap-adapter.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..686135a --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# O-RAN-SC Non-RealTime RIC DMaaP Information Producer +This product is a generic information producer (as defined by the Information Coordinator Service (ICS)). It can produce any information that can be retrieved from DMaaP or Kafka. Its main tasks is to register information types and itself as a producer using the ICS Data Producer API. + +A data consumer may create information jobs through the ICS Data Producer API. + +This service will retrieve data from the DMaaP Message Router (MR) or from the Kafka streaming platform and will distribute it further to the data consumers (information job owners). + +The component is a springboot service and is configured as any springboot service through the file `config/application.yaml`. The component log can be retrieved and logging can be controled by means of REST call. See the API documentation (api/api.yaml). + +The file `config/application_configuration.json` contains the configuration of job types that the producer will support. Here follows an example with one type: + +```sh + { + "types": + [ + { + "id": "ExampleInformationType1_1.0.0", + "dmaapTopicUrl": "events/unauthenticated.SEC_FAULT_OUTPUT/dmaapmediatorproducer/STD-Fault-Messages_1.0.0", + "useHttpProxy": true + }, + { + "id": "ExampleInformationType2_2.0.0", + "kafkaInputTopic": "KafkaInputTopic", + "useHttpProxy": false + } + ] + } +``` + +Each information type has the following properties: + - id the information type identity as exposed in the Information Coordination Service data consumer API + - dmaapTopicUrl the URL to for fetching information from DMaaP + - kafkaInputTopic a Kafka topic to get input from + - useHttpProxy if true, the received information will be delivered using a HTTP proxy (provided that one is setup in the application.yaml file). This might for instance be needed if the data consumer is in the RAN or outside the cluster. + +The service producer will poll MR and/or listen to Kafka topics for all configured job types. When receiving messages for a type, it will distribute these messages to all jobs registered for the type. If a consumer is unavailable for distribution, the messages will be discarded for that consumer. + +When an Information Job is created in the Information Coordinator Service Consumer API, it is possible to define a number of job specific properties. For an Information type that has a Kafka topic defined, the following Json schema defines the properties that can be used: + + +```sh +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "filter": { + "type": "string" + }, + "maxConcurrency": { + "type": "integer" + }, + "bufferTimeout": { + "type": "object", + "properties": { + "maxSize": { + "type": "integer" + }, + "maxTimeMiliseconds": { + "type": "integer" + } + }, + "additionalProperties": false, + "required": [ + "maxSize", + "maxTimeMiliseconds" + ] + } + }, + "additionalProperties": false +} +``` +-filter is a regular expression. Only strings that matches the expression will be pushed further to the consumer. +-maxConcurrency the maximum number of concurrent REST session for the data delivery to the consumer. + The default is 1 and that is the number that must be used to guarantee that the object sequence is maintained. + A higher number will give higher throughtput. +-bufferTimeout, can be used to reduce the number of REST calls to the consumer. If defined, a number of objects will be + buffered and sent in one REST call to the consumer. + The buffered objects will be put in a Json array and quoted. Example; + Object1 and Object2 may be posted in one call --> ["Object1", "Object2"] + The bufferTimeout is a Json object and the parameters in the object are: + - maxSize the maximum number of buffered objects before posting + - maxTimeMiliseconds the maximum delay time to buffer before posting + If no bufferTimeout is specified, each object will be posted as received in separate calls (not quoted and put in a Json array). + + +For an information type that only has a DMaaP topic, the following Json schema defines the possible parameters to use when creating an information job: + +```sh +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "filter": { + "type": "string" + } + }, + "additionalProperties": false +} +``` +-filter is a regular expression. Only strings that matches the expression will be pushed furter to the consumer. This + has a similar meaning as in jobs that receives data from Kafka. + +## License + +Copyright (C) 2021 Nordix Foundation. 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 + +http://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. +For more information about license please see the [LICENSE](LICENSE.txt) file for details. diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..e69de29 diff --git a/api/api.json b/api/api.json new file mode 100644 index 0000000..88fed46 --- /dev/null +++ b/api/api.json @@ -0,0 +1,409 @@ +{ + "components": {"schemas": { + "producer_info_job_request": { + "description": "The body of the Information Producer callbacks for Information Job creation and deletion", + "type": "object", + "required": ["info_job_identity"], + "properties": { + "owner": { + "description": "The owner of the job", + "type": "string" + }, + "last_updated": { + "description": "The time when the job was last updated or created (ISO-8601)", + "type": "string" + }, + "info_job_identity": { + "description": "Identity of the Information Job", + "type": "string" + }, + "target_uri": { + "description": "URI for the target of the produced Information", + "type": "string" + }, + "info_job_data": { + "description": "Json for the job data", + "type": "object" + }, + "info_type_identity": { + "description": "Type identity for the job", + "type": "string" + } + } + }, + "error_information": { + "description": "Problem as defined in https://tools.ietf.org/html/rfc7807", + "type": "object", + "properties": { + "detail": { + "description": " A human-readable explanation specific to this occurrence of the problem.", + "type": "string", + "example": "Policy type not found" + }, + "status": { + "format": "int32", + "description": "The HTTP status code generated by the origin server for this occurrence of the problem. ", + "type": "integer", + "example": 503 + } + } + }, + "void": { + "description": "Void/empty", + "type": "object" + }, + "producer_registration_info": { + "description": "Information for an Information Producer", + "type": "object", + "required": [ + "info_job_callback_url", + "info_producer_supervision_callback_url", + "supported_info_types" + ], + "properties": { + "info_producer_supervision_callback_url": { + "description": "callback for producer supervision", + "type": "string" + }, + "supported_info_types": { + "description": "Supported Information Type IDs", + "type": "array", + "items": { + "description": "Supported Information Type IDs", + "type": "string" + } + }, + "info_job_callback_url": { + "description": "callback for Information Job", + "type": "string" + } + } + }, + "Link": { + "type": "object", + "properties": { + "templated": {"type": "boolean"}, + "href": {"type": "string"} + } + }, + "producer_info_type_info": { + "description": "Information for an Information Type", + "type": "object", + "required": [ + "info_job_data_schema", + "info_type_information" + ], + "properties": { + "info_type_information": { + "description": "Type specific information for the information type", + "type": "object" + }, + "info_job_data_schema": { + "description": "Json schema for the job data", + "type": "object" + } + } + } + }}, + "openapi": "3.0.1", + "paths": { + "/actuator/threaddump": {"get": { + "summary": "Actuator web endpoint 'threaddump'", + "operationId": "threaddump_4", + "responses": {"200": { + "description": "OK", + "content": {"*/*": {"schema": {"type": "object"}}} + }}, + "tags": ["Actuator"] + }}, + "/actuator/info": {"get": { + "summary": "Actuator web endpoint 'info'", + "operationId": "info_2", + "responses": {"200": { + "description": "OK", + "content": {"*/*": {"schema": {"type": "object"}}} + }}, + "tags": ["Actuator"] + }}, + "/data-producer/v1/info-types/{infoTypeId}": {"put": { + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/producer_info_type_info"}}}, + "required": true + }, + "operationId": "putInfoType", + "responses": {"200": { + "description": "OK", + "content": {"application/json": {"schema": {"type": "object"}}} + }}, + "parameters": [{ + "schema": {"type": "string"}, + "in": "path", + "name": "infoTypeId", + "required": true + }], + "tags": ["Information Coordinator Service Simulator (exists only in test)"] + }}, + "/generic_dataproducer/health_check": {"get": { + "summary": "Producer supervision", + "description": "The endpoint is provided by the Information Producer and is used for supervision of the producer.", + "operationId": "producerSupervision", + "responses": {"200": { + "description": "The producer is OK", + "content": {"application/json": {"schema": {"type": "string"}}} + }}, + "tags": ["Producer job control API"] + }}, + "/generic_dataproducer/info_job": { + "post": { + "summary": "Callback for Information Job creation/modification", + "requestBody": { + "content": {"application/json": {"schema": {"type": "string"}}}, + "required": true + }, + "description": "The call is invoked to activate or to modify a data subscription. The endpoint is provided by the Information Producer.", + "operationId": "jobCreatedCallback", + "responses": { + "200": { + "description": "OK", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/void"}}} + }, + "400": { + "description": "Other error in the request", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/error_information"}}} + }, + "404": { + "description": "Information type is not found", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/error_information"}}} + } + }, + "tags": ["Producer job control API"] + }, + "get": { + "summary": "Get all jobs", + "description": "Returns all info jobs, can be used for trouble shooting", + "operationId": "getJobs", + "responses": {"200": { + "description": "Information jobs", + "content": {"application/json": {"schema": { + "type": "array", + "items": {"$ref": "#/components/schemas/producer_info_job_request"} + }}} + }}, + "tags": ["Producer job control API"] + } + }, + "/actuator/loggers": {"get": { + "summary": "Actuator web endpoint 'loggers'", + "operationId": "loggers_2", + "responses": {"200": { + "description": "OK", + "content": {"*/*": {"schema": {"type": "object"}}} + }}, + "tags": ["Actuator"] + }}, + "/actuator/health/**": {"get": { + "summary": "Actuator web endpoint 'health-path'", + "operationId": "health-path_2", + "responses": {"200": { + "description": "OK", + "content": {"*/*": {"schema": {"type": "object"}}} + }}, + "tags": ["Actuator"] + }}, + "/data-producer/v1/info-producers/{infoProducerId}": { + "get": { + "operationId": "getInfoProducer", + "responses": {"200": { + "description": "OK", + "content": {"application/json": {"schema": {"type": "object"}}} + }}, + "parameters": [{ + "schema": {"type": "string"}, + "in": "path", + "name": "infoProducerId", + "required": true + }], + "tags": ["Information Coordinator Service Simulator (exists only in test)"] + }, + "put": { + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/producer_registration_info"}}}, + "required": true + }, + "operationId": "putInfoProducer", + "responses": {"200": { + "description": "OK", + "content": {"application/json": {"schema": {"type": "object"}}} + }}, + "parameters": [{ + "schema": {"type": "string"}, + "in": "path", + "name": "infoProducerId", + "required": true + }], + "tags": ["Information Coordinator Service Simulator (exists only in test)"] + } + }, + "/generic_dataproducer/info_job/{infoJobId}": {"delete": { + "summary": "Callback for Information Job deletion", + "description": "The call is invoked to terminate a data subscription. The endpoint is provided by the Information Producer.", + "operationId": "jobDeletedCallback", + "responses": {"200": { + "description": "OK", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/void"}}} + }}, + "parameters": [{ + "schema": {"type": "string"}, + "in": "path", + "name": "infoJobId", + "required": true + }], + "tags": ["Producer job control API"] + }}, + "/actuator/metrics/{requiredMetricName}": {"get": { + "summary": "Actuator web endpoint 'metrics-requiredMetricName'", + "operationId": "metrics-requiredMetricName_2", + "responses": {"200": { + "description": "OK", + "content": {"*/*": {"schema": {"type": "object"}}} + }}, + "parameters": [{ + "schema": {"type": "string"}, + "in": "path", + "name": "requiredMetricName", + "required": true + }], + "tags": ["Actuator"] + }}, + "/actuator": {"get": { + "summary": "Actuator root web endpoint", + "operationId": "links_1", + "responses": {"200": { + "description": "OK", + "content": {"*/*": {"schema": { + "additionalProperties": { + "additionalProperties": {"$ref": "#/components/schemas/Link"}, + "type": "object" + }, + "type": "object" + }}} + }}, + "tags": ["Actuator"] + }}, + "/actuator/logfile": {"get": { + "summary": "Actuator web endpoint 'logfile'", + "operationId": "logfile_2", + "responses": {"200": { + "description": "OK", + "content": {"*/*": {"schema": {"type": "object"}}} + }}, + "tags": ["Actuator"] + }}, + "/actuator/loggers/{name}": { + "post": { + "summary": "Actuator web endpoint 'loggers-name'", + "operationId": "loggers-name_3", + "responses": {"200": { + "description": "OK", + "content": {"*/*": {"schema": {"type": "object"}}} + }}, + "parameters": [{ + "schema": {"type": "string"}, + "in": "path", + "name": "name", + "required": true + }], + "tags": ["Actuator"] + }, + "get": { + "summary": "Actuator web endpoint 'loggers-name'", + "operationId": "loggers-name_4", + "responses": {"200": { + "description": "OK", + "content": {"*/*": {"schema": {"type": "object"}}} + }}, + "parameters": [{ + "schema": {"type": "string"}, + "in": "path", + "name": "name", + "required": true + }], + "tags": ["Actuator"] + } + }, + "/actuator/health": {"get": { + "summary": "Actuator web endpoint 'health'", + "operationId": "health_2", + "responses": {"200": { + "description": "OK", + "content": {"*/*": {"schema": {"type": "object"}}} + }}, + "tags": ["Actuator"] + }}, + "/consumer": {"post": { + "summary": "Consume data", + "requestBody": { + "content": {"application/json": {"schema": {"type": "string"}}}, + "required": true + }, + "description": "The call is invoked to push data to consumer", + "operationId": "postData", + "responses": {"200": { + "description": "OK", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/void"}}} + }}, + "tags": ["Test Consumer Simulator (exists only in test)"] + }}, + "/dmaap-topic-1": {"get": { + "summary": "GET from topic", + "description": "The call is invoked to activate or to modify a data subscription. The endpoint is provided by the Information Producer.", + "operationId": "getFromTopic", + "responses": {"200": { + "description": "OK", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/void"}}} + }}, + "tags": ["DMAAP Simulator (exists only in test)"] + }}, + "/actuator/metrics": {"get": { + "summary": "Actuator web endpoint 'metrics'", + "operationId": "metrics_2", + "responses": {"200": { + "description": "OK", + "content": {"*/*": {"schema": {"type": "object"}}} + }}, + "tags": ["Actuator"] + }}, + "/actuator/heapdump": {"get": { + "summary": "Actuator web endpoint 'heapdump'", + "operationId": "heapdump_2", + "responses": {"200": { + "description": "OK", + "content": {"*/*": {"schema": {"type": "object"}}} + }}, + "tags": ["Actuator"] + }} + }, + "info": { + "license": { + "name": "Copyright (C) 2021 Nordix Foundation. Licensed under the Apache License.", + "url": "http://www.apache.org/licenses/LICENSE-2.0" + }, + "description": "Reads data from DMaaP and Kafka and posts it further to information consumers", + "title": "Generic Dmaap and Kafka Information Producer", + "version": "1.0" + }, + "tags": [ + {"name": "Information Coordinator Service Simulator (exists only in test)"}, + {"name": "Producer job control API"}, + {"name": "Test Consumer Simulator (exists only in test)"}, + {"name": "DMAAP Simulator (exists only in test)"}, + { + "name": "Actuator", + "description": "Monitor and interact", + "externalDocs": { + "description": "Spring Boot Actuator Web API Documentation", + "url": "https://docs.spring.io/spring-boot/docs/current/actuator-api/html/" + } + } + ] +} \ No newline at end of file diff --git a/api/api.yaml b/api/api.yaml new file mode 100644 index 0000000..f6eb1f7 --- /dev/null +++ b/api/api.yaml @@ -0,0 +1,482 @@ +openapi: 3.0.1 +info: + title: Generic Dmaap and Kafka Information Producer + description: Reads data from DMaaP and Kafka and posts it further to information + consumers + license: + name: Copyright (C) 2021 Nordix Foundation. Licensed under the Apache License. + url: http://www.apache.org/licenses/LICENSE-2.0 + version: "1.0" +servers: +- url: / +tags: +- name: Information Coordinator Service Simulator (exists only in test) +- name: Producer job control API +- name: Test Consumer Simulator (exists only in test) +- name: DMAAP Simulator (exists only in test) +- name: Actuator + description: Monitor and interact + externalDocs: + description: Spring Boot Actuator Web API Documentation + url: https://docs.spring.io/spring-boot/docs/current/actuator-api/html/ +paths: + /actuator/threaddump: + get: + tags: + - Actuator + summary: Actuator web endpoint 'threaddump' + operationId: threaddump_4 + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object + /actuator/info: + get: + tags: + - Actuator + summary: Actuator web endpoint 'info' + operationId: info_2 + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object + /data-producer/v1/info-types/{infoTypeId}: + put: + tags: + - Information Coordinator Service Simulator (exists only in test) + operationId: putInfoType + parameters: + - name: infoTypeId + in: path + required: true + style: simple + explode: false + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/producer_info_type_info' + required: true + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + /generic_dataproducer/health_check: + get: + tags: + - Producer job control API + summary: Producer supervision + description: The endpoint is provided by the Information Producer and is used + for supervision of the producer. + operationId: producerSupervision + responses: + 200: + description: The producer is OK + content: + application/json: + schema: + type: string + /generic_dataproducer/info_job: + get: + tags: + - Producer job control API + summary: Get all jobs + description: Returns all info jobs, can be used for trouble shooting + operationId: getJobs + responses: + 200: + description: Information jobs + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/producer_info_job_request' + post: + tags: + - Producer job control API + summary: Callback for Information Job creation/modification + description: The call is invoked to activate or to modify a data subscription. + The endpoint is provided by the Information Producer. + operationId: jobCreatedCallback + requestBody: + content: + application/json: + schema: + type: string + required: true + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/void' + 400: + description: Other error in the request + content: + application/json: + schema: + $ref: '#/components/schemas/error_information' + 404: + description: Information type is not found + content: + application/json: + schema: + $ref: '#/components/schemas/error_information' + /actuator/loggers: + get: + tags: + - Actuator + summary: Actuator web endpoint 'loggers' + operationId: loggers_2 + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object + /actuator/health/**: + get: + tags: + - Actuator + summary: Actuator web endpoint 'health-path' + operationId: health-path_2 + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object + /data-producer/v1/info-producers/{infoProducerId}: + get: + tags: + - Information Coordinator Service Simulator (exists only in test) + operationId: getInfoProducer + parameters: + - name: infoProducerId + in: path + required: true + style: simple + explode: false + schema: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + put: + tags: + - Information Coordinator Service Simulator (exists only in test) + operationId: putInfoProducer + parameters: + - name: infoProducerId + in: path + required: true + style: simple + explode: false + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/producer_registration_info' + required: true + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + /generic_dataproducer/info_job/{infoJobId}: + delete: + tags: + - Producer job control API + summary: Callback for Information Job deletion + description: The call is invoked to terminate a data subscription. The endpoint + is provided by the Information Producer. + operationId: jobDeletedCallback + parameters: + - name: infoJobId + in: path + required: true + style: simple + explode: false + schema: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/void' + /actuator/metrics/{requiredMetricName}: + get: + tags: + - Actuator + summary: Actuator web endpoint 'metrics-requiredMetricName' + operationId: metrics-requiredMetricName_2 + parameters: + - name: requiredMetricName + in: path + required: true + style: simple + explode: false + schema: + type: string + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object + /actuator: + get: + tags: + - Actuator + summary: Actuator root web endpoint + operationId: links_1 + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object + additionalProperties: + type: object + additionalProperties: + $ref: '#/components/schemas/Link' + /actuator/logfile: + get: + tags: + - Actuator + summary: Actuator web endpoint 'logfile' + operationId: logfile_2 + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object + /actuator/loggers/{name}: + get: + tags: + - Actuator + summary: Actuator web endpoint 'loggers-name' + operationId: loggers-name_4 + parameters: + - name: name + in: path + required: true + style: simple + explode: false + schema: + type: string + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object + post: + tags: + - Actuator + summary: Actuator web endpoint 'loggers-name' + operationId: loggers-name_3 + parameters: + - name: name + in: path + required: true + style: simple + explode: false + schema: + type: string + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object + /actuator/health: + get: + tags: + - Actuator + summary: Actuator web endpoint 'health' + operationId: health_2 + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object + /consumer: + post: + tags: + - Test Consumer Simulator (exists only in test) + summary: Consume data + description: The call is invoked to push data to consumer + operationId: postData + requestBody: + content: + application/json: + schema: + type: string + required: true + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/void' + /dmaap-topic-1: + get: + tags: + - DMAAP Simulator (exists only in test) + summary: GET from topic + description: The call is invoked to activate or to modify a data subscription. + The endpoint is provided by the Information Producer. + operationId: getFromTopic + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/void' + /actuator/metrics: + get: + tags: + - Actuator + summary: Actuator web endpoint 'metrics' + operationId: metrics_2 + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object + /actuator/heapdump: + get: + tags: + - Actuator + summary: Actuator web endpoint 'heapdump' + operationId: heapdump_2 + responses: + 200: + description: OK + content: + '*/*': + schema: + type: object +components: + schemas: + producer_info_job_request: + required: + - info_job_identity + type: object + properties: + owner: + type: string + description: The owner of the job + last_updated: + type: string + description: The time when the job was last updated or created (ISO-8601) + info_job_identity: + type: string + description: Identity of the Information Job + target_uri: + type: string + description: URI for the target of the produced Information + info_job_data: + type: object + description: Json for the job data + info_type_identity: + type: string + description: Type identity for the job + description: The body of the Information Producer callbacks for Information + Job creation and deletion + error_information: + type: object + properties: + detail: + type: string + description: ' A human-readable explanation specific to this occurrence + of the problem.' + example: Policy type not found + status: + type: integer + description: 'The HTTP status code generated by the origin server for this + occurrence of the problem. ' + format: int32 + example: 503 + description: Problem as defined in https://tools.ietf.org/html/rfc7807 + void: + type: object + description: Void/empty + producer_registration_info: + required: + - info_job_callback_url + - info_producer_supervision_callback_url + - supported_info_types + type: object + properties: + info_producer_supervision_callback_url: + type: string + description: callback for producer supervision + supported_info_types: + type: array + description: Supported Information Type IDs + items: + type: string + description: Supported Information Type IDs + info_job_callback_url: + type: string + description: callback for Information Job + description: Information for an Information Producer + Link: + type: object + properties: + templated: + type: boolean + href: + type: string + producer_info_type_info: + required: + - info_job_data_schema + - info_type_information + type: object + properties: + info_type_information: + type: object + description: Type specific information for the information type + info_job_data_schema: + type: object + description: Json schema for the job data + description: Information for an Information Type diff --git a/config/README b/config/README new file mode 100644 index 0000000..140927f --- /dev/null +++ b/config/README @@ -0,0 +1,41 @@ +The keystore.jks and truststore.jks files are created by using the following commands (note that this is an example): + +1) Create a CA certificate and a private key: + +openssl genrsa -des3 -out CA-key.pem 2048 +openssl req -new -key CA-key.pem -x509 -days 1000 -out CA-cert.pem + +2) Create a keystore with a private key entry that is signed by the CA: + +keytool -genkeypair -alias policy_agent -keyalg RSA -keysize 2048 -keystore keystore.jks -validity 3650 -storepass policy_agent +keytool -certreq -alias policy_agent -file request.csr -keystore keystore.jks -ext san=dns:your.domain.com -storepass policy_agent +openssl x509 -req -days 365 -in request.csr -CA CA-cert.pem -CAkey CA-key.pem -CAcreateserial -out ca_signed-cert.pem +keytool -importcert -alias ca_cert -file CA-cert.pem -keystore keystore.jks -trustcacerts -storepass policy_agent +keytool -importcert -alias policy_agent -file ca_signed-cert.pem -keystore keystore.jks -trustcacerts -storepass policy_agent + + +3) Create a trust store containing the CA cert (to trust all certs signed by the CA): + +keytool -genkeypair -alias not_used -keyalg RSA -keysize 2048 -keystore truststore.jks -validity 3650 -storepass policy_agent +keytool -importcert -alias ca_cert -file CA-cert.pem -keystore truststore.jks -trustcacerts -storepass policy_agent + + +4) Command for listing of the contents of jks files, examples: +keytool -list -v -keystore keystore.jks -storepass policy_agent +keytool -list -v -keystore truststore.jks -storepass policy_agent + +## License + +Copyright (C) 2020 Nordix Foundation. All rights reserved. +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 + + http://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. + diff --git a/config/application.yaml b/config/application.yaml new file mode 100644 index 0000000..9b4b682 --- /dev/null +++ b/config/application.yaml @@ -0,0 +1,60 @@ +spring: + profiles: + active: prod + main: + allow-bean-definition-overriding: true + aop: + auto: false +management: + endpoints: + web: + exposure: + # Enabling of springboot actuator features. See springboot documentation. + include: "loggers,logfile,health,info,metrics,threaddump,heapdump" +springdoc: + show-actuator: true +logging: + # Configuration of logging + level: + ROOT: ERROR + org.springframework: ERROR + org.springframework.data: ERROR + org.springframework.web.reactive.function.client.ExchangeFunctions: ERROR + org.oran.dmaapadapter: INFO + file: + name: /var/log/dmaap-adapter-service/application.log +server: + # Configuration of the HTTP/REST server. The parameters are defined and handeled by the springboot framework. + # See springboot documentation. + port : 8435 + http-port: 8084 + ssl: + key-store-type: JKS + key-store-password: policy_agent + key-store: /opt/app/dmaap-adapter-service/etc/cert/keystore.jks + key-password: policy_agent + key-alias: policy_agent +app: + webclient: + # Configuration of the trust store used for the HTTP client (outgoing requests) + # The file location and the password for the truststore is only relevant if trust-store-used == true + # Note that the same keystore as for the server is used. + trust-store-used: false + trust-store-password: policy_agent + trust-store: /opt/app/dmaap-adapter-service/etc/cert/truststore.jks + # Configuration of usage of HTTP Proxy for the southbound accesses. + # The HTTP proxy (if configured) will only be used for accessing NearRT RIC:s + http.proxy-host: + http.proxy-port: 0 + ics-base-url: https://localhost:8434 + # Location of the component configuration file. The file will only be used if the Consul database is not used; + # configuration from the Consul will override the file. + configuration-filepath: /opt/app/dmaap-adapter-service/data/application_configuration.json + dmaap-base-url: http://dradmin:dradmin@localhost:2222 + # The url used to adress this component. This is used as a callback url sent to other components. + dmaap-adapter-base-url: https://localhost:8435 + # KAFKA boostrap servers. This is only needed if there are Information Types that uses a kafkaInputTopic + # several redundant boostrap servers can be specified, separated by a comma ','. + kafka: + bootstrap-servers: localhost:9092 + diff --git a/config/application_configuration.json b/config/application_configuration.json new file mode 100644 index 0000000..3233804 --- /dev/null +++ b/config/application_configuration.json @@ -0,0 +1,14 @@ +{ + "types": [ + { + "id": "ExampleInformationType1", + "dmaapTopicUrl": "/events/unauthenticated.VES_NOTIFICATION_OUTPUT/OpenDcae-c12/C12", + "useHttpProxy": true + }, + { + "id": "ExampleInformationType2", + "kafkaInputTopic": "TutorialTopic", + "useHttpProxy": false + } + ] +} diff --git a/config/keystore.jks b/config/keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..122997ac7b9ad8a58b7a167916bfdf808f3f3e4b GIT binary patch literal 4987 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 literal 0 HcmV?d00001 diff --git a/docs/_static/logo.png b/docs/_static/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c3b6ce56468d87a3d9463ee75297b3895fc9a414 GIT binary patch literal 43935 zcmdRV1y>y1vgqK!-8I48-Gh5@cL>34aCe8`1P=r!c+dd`w=lT7ySqQWbI(2RuDjOz z0k3=Y>h9{2y{mTbUD6%(MOg+Fi3kY*0HDgrN~!?>P>%11%Ln*(hyYMu_5B2EC9Wh6 z0MsTRKbyk6-&2^&swn{g-ZTI}U?>3a_znu(2LRmI0Dwc2cd85k0N*LQRaFQ8fJU~~ z&;jZwDGHc7+OwEiIGS0qc-lL?Qvd)$o&xWe_Le|X3Qv1G2Uh`4;ZOg<5O}}-2h943 z;$J90Tj5VSN?#}>99=9axLLSZ*glCMQBY6_xmbJ^P?MDYH~IUW@TYG;ppyVAtA~dN ziw7r*ql* z8~@8UN1)@ou3fCniaL-{0sVS91$cT*8dn< z1PNV=AsGM=1IS5=Yj{E(wr@+G9 z^hp-7PIS_oS-wV1l&UDjW@2DR(VS63abL7hvm-UveBfha`_O~J*>n6Ch4Zg_x~uZs zlYhn2oMFis{~c4)n9)s(U-{wP(@b@_zv0bE>x|}%JpUfk`u`XEI1RF={qTV07Ss1A zxa)R&l(L`QHN)xB(-ZtX4BD9`!IQp4Aby7NtCW%mB^?C*q7W&0AO7UpSv_lR+VL(S zV=rumFA=!@pAd3AK1|T}(x%LAmE(|T`kxYX%VoR_$B?Rj)3@^6i}b6r=Dh@94f7mvwg=$i2|8C8>s4Z+BE|#j7a|i(e8Cflj>OJJ+*TJDSI<|xy{gFKL94>cfm zP%x865OQ^itF$s9v6fP0C_o;Pm;v0NAYoaaDOi`-DHj>oM@RJC{tMrv5?lP%2UiUH z^K@YNsLHk!7Y%g|lYo5?Z`7}mzWhO~b;fh?;7FQza;P^7GK{B>&7=&C_d!j%6_fpH z>%!~?*SDoG$mrf@E|l)`11+&k>tI1j2?6d@X2A2+%xw&wX^Jn1;ob`B6atNs2eU2& zQ@Dy7$%=44Dt`DeRO0*>15D+$64mukLc!0<-a_D%MO2mSmtx+eA~OjYd^!aguVm!; zL_4>0WsBGjy5B=MuyGS@vk1b!J>Z6yP)>V{Foc57DJ9gsvjm)H{Lhk>qD>u6=XCSg zk@?OY{u(KJ!y^Ogh5qbmx=wG`Zb9i;Nn8t2Y&;O)!ZfreLk-42eal!3j0s|gmX(N+ z?sm>_p}~6mNGEMNoP>aX#DS^6!+6fqBI*4juUD!whxH*5&|YdM>5{DMswYhjZH2Iz zKmnRne}(BYyGTs@X_D@7bL(v53en9^gCp^rtNXsC{RiMQq^;6!k}JSdC;!mp1t>%Y zOpn1+k@tB7*EYFp-iYS(5R9l+t-~ zcG9a#K^_^72IzSGSz@a05=%WbYwax*%_SI~M>6TCC)X@%d~&LL*>IEZgEOZwGG^=> zRQnaQqVr!-LX#IeTv&XlXBIJN#h{D>bcm8nK0z7gI#VGw7kzqKY9jB$Cy9^47j-=+ zu#W1%r6{VpvY)*a6BTRE&4&plhm;S+w5U4i4KsEC@-H|75qvA*Rihb2|K{ngd^Ck(wn=336Zn** zlw7m1QDTQkZi#9Vc7XEA0cD^oFnkeB5H3R~5ouu?;?Cp}n^ zkLxmZ(h#JjgR*5NxL@DpviG@uMY5Vc!UM&5H?Nfdl;%GPH)L7l#Z2(LCNOEtnNsm! z%bH9%kS9nV3Sv51K6}gZbjk^T{iex^3;XJ`m>YwXMW$PSzH1I}uf{Q3hlO?`k%)1=#Oa^)S;wD{rdTha!q# zRY_J$at2x!VUsp8^+bTobF7hg^#-=ikABGlCk=o^!o6kdWF2P^Z%Rg;E~R6k_9ayoM{IqJEZ+uk z(qPKP54xn7`yFuBrV5PXP=(N%3#lod>BDC~XRn?I*WlYR_MI!{F-AWR&pAk(gC2FA zF1Z&P=xP6}Rs|A3umtT?SPpf^2%fDK+N2jhVcw={SUUm(;R7EKTJdwt;GY8wJ9!== z#4J!7A}Ef#TYyks)_@GH7>p-c4od7xctG|ewo7)p2gnsGNj8$5$6Kg|;&?{vAkum_ zN=yZ8xcmEAPz6MItfYmgqGr!+{j)4>@j4Z69)+;#R$dm44Q!tr)z<8%eiz2o?qEWF z41gX)B%tDo84G1EMIQYAA-y<#g2|t71yYUWS?}a@U7808WsC!<`$o&RM&hO?$}4(J zJda#UKPu@Bvw>MYZG?^ifBKIP-z(xIbqfvtnJT&Gtma}b@7XR0@t?L=A}u)HbkZoH z%ldBXpRjpn)DWWY^mWcd`p(X)do)ge_MppT8p*q6yJ`@ZIiFj3MX>dr(Pt6JC~hqrUitn?hPUGDb{+Zn;|{8c zyR)^WHKAv9(;z_){MV9Pz%z3gPP?Gz6)AANr4$L>t9(sPhF2^lP`qv#z1++x>i+mP zNxA!PZF#(gz%Ym552N84{>cIAR>?Fj?D%_{VYbL!j!-(o$Z{GvU9!Z=K#x)GWmPZ5 zj(fxzj_^;PI{D&jQa&H~|b zdPv2V9#~1*o@Nrer|nmLZZ}gT_gYt=RT=kh%~~%P@WzRHGiw9=FE_nxluxPmO**5l(>L`dV{`f94&LrDNg~BDvDo>f zd}*SswVJWovEsZm3YNIAX`nxuY!`w`QW@30rbi&k^3(y;5oHRT7k(g2jIQRJTl|=1 zQ=(AA#?qkz{NAM{a)MLeC@K+?(7|7K4KRmYhOa@)SELn~65&?wcemtF?G5A&!=U|& z-HUkYb?-&Ol2wRq)DgU?Y_$9KYs+YUO=4#yg0kl$s7;j-4xMH!lLga zgg$hzxk$bZjz+2QexapzVHDJbPxrt@&NxUcd{vmxU5i8OCH+359j7tDPPqVp^0&)F zguNyzmT5hlR25HytG(j6C>4^X?I8?onusY@(WC~PP>DSf6vzl`3gAIELfM?7i1l#t zB&~My<=nf^4>n?el#JBA|3cYWSN_Ns=|#pM$A&LP&PZr-7ta29FVawB!3|M;sZ(Xk z>%x%ksbXTixo!CnXNfBZdF1zz0ipq9iU}|dsgU5anHgzEaLuX?0e4$}*2Vag&rU&= zFV~LM_+SZSLt$l*WFns!v0(O7q&lFAOQepjqfcVxz(J7<$BVEH;=Nb=dKm=_OM$Iq zk4B4sEbmppFt@Xop3TNGRiiQ0o8qjz{uPy6bN@>rLj)&-&Oej{!R&Ji4QYbgLR29~ zRHg7`{5Mm-9|X$VihPq2M6A;ze~Zvz#${kqDKn7sku%FOOGN?yK8!ab{!QezDQ@{w3(@!ND*C<;;`P9=58 zQc?Iz@=V)s(XlZ-_kO~{Fuwk^^||Q^el$hSf*8(mg0LiE7`JZ}#1GCE%gsVy%L_rK zyt>D2{#BSeZr|6kY0QEfoV5$G+T-~s(AoaavSs9>J*sr(cj3#;aanOccm0CVy|8d0 z+Mqf0H`-Ky%6~tL5lNjs(Tu1aI*tRHLs}#Wlk{V}-lJl$Y}4T6-VB|m%z+Zwhb*B7 z80a^)*4@F@(9v}$nfZ)19@iOZw#TTX9~q%#THR{ANWEN)266Ck;bD0?lF*wm5TL=n z`|95$mrq%^o&#PV#RSzv9YT0vDG&npcDFKPyj)|)N3&a)9o0PVY;k=ZfG)?zD<5^Z zlznnw&Tbcd^c?n6as_Uqkte)FV0U+#`->6(1e*i`M6clhsBsr`CTTlzyZMCty-`#+ z?DzreihBv&H1Rc2(jql6ai0ed5Php(*hH|RG)JJM z7hs*}>n)VHOy~^4nKWgm^1(Hg+nQIE#UY4Hx%A1s`cSLWq(=$eur8qe zZCRntns4B2b>^>t{(

r@f1gRWk|A*%4`3la7*fhEBO++ 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 literal 0 HcmV?d00001 diff --git a/docs/api-docs.rst b/docs/api-docs.rst new file mode 100644 index 0000000..ad070ec --- /dev/null +++ b/docs/api-docs.rst @@ -0,0 +1,34 @@ +.. This work is licensed under a Creative Commons Attribution 4.0 International License. +.. http://creativecommons.org/licenses/by/4.0 +.. Copyright (C) 2021 Nordix + +.. _api_docs: + +.. |swagger-icon| image:: ./images/swagger.png + :width: 40px + +.. |yaml-icon| image:: ./images/yaml_logo.png + :width: 40px + + +======== +API-Docs +======== + +Here we describe the APIs to access the Non-RT RIC DMaaP Adapter. + +DMaaP Adapter +============= + +The DMaaP Adapter provides support for push delivery of any data received from DMaaP or Kafka. + +See `DMaaP Adapter API <./dmaap-adapter-api.html>`_ for full details of the API. + +The API is also described in Swagger-JSON and YAML: + + +.. csv-table:: + :header: "API name", "|swagger-icon|", "|yaml-icon|" + :widths: 10,5, 5 + + "DMaaP Adapter API", ":download:`link <../api/api.json>`", ":download:`link <../api/api.yaml>`" diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..4cc4265 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,24 @@ +from docs_conf.conf import * + +#branch configuration + +branch = 'latest' + +linkcheck_ignore = [ + 'http://localhost.*', + 'http://127.0.0.1.*', + 'https://gerrit.o-ran-sc.org.*', + './dmaap-adapter-api.html', #Generated file that doesn't exist at link check. +] + +extensions = ['sphinxcontrib.redoc', 'sphinx.ext.intersphinx',] + +redoc = [ + { + 'name': 'DMaaP Adapter API', + 'page': 'dmaap-adapter-api', + 'spec': '../api/api.json', + } + ] + +redoc_uri = 'https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js' diff --git a/docs/conf.yaml b/docs/conf.yaml new file mode 100644 index 0000000..84bfbac --- /dev/null +++ b/docs/conf.yaml @@ -0,0 +1,3 @@ +--- +project_cfg: oran +project: nonrtric-plt-dmaapadapter diff --git a/docs/developer-guide.rst b/docs/developer-guide.rst new file mode 100644 index 0000000..c9a4026 --- /dev/null +++ b/docs/developer-guide.rst @@ -0,0 +1,32 @@ +.. This work is licensed under a Creative Commons Attribution 4.0 International License. +.. SPDX-License-Identifier: CC-BY-4.0 +.. Copyright (C) 2021 Nordix + +Developer Guide +=============== + +This document provides a quickstart for developers of the Non-RT RIC DMaaP Adapter. + +Additional developer guides are available on the `O-RAN SC NONRTRIC Developer wiki `_. + +DMaaP Adapter Service +--------------------- + +This Java implementation is run in the same way as the Information Coordinator Service. + +The following properties in the application.yaml file have to be modified: +* server.ssl.key-store=./config/keystore.jks +* app.webclient.trust-store=./config/truststore.jks +* app.configuration-filepath=./src/test/resources/test_application_configuration.json + +Kubernetes deployment +===================== + +Non-RT RIC can be also deployed in a Kubernetes cluster, `it/dep repository `_. +hosts deployment and integration artifacts. Instructions and helm charts to deploy the Non-RT-RIC functions in the +OSC NONRTRIC integrated test environment can be found in the *./nonrtric* directory. + +For more information on installation of NonRT-RIC in Kubernetes, see `Deploy NONRTRIC in Kubernetes `_. + +For more information see `Integration and Testing documentation on the O-RAN-SC wiki `_. + diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..00b0fd0ef0b4e78fbb8cdb413ce84561dfeb404f GIT binary patch literal 15086 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

4K$ruCE1 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 XY+}&}KC=mD;X?Iq*l*cA?B4$ePY*3q literal 0 HcmV?d00001 diff --git a/docs/images/swagger.png b/docs/images/swagger.png new file mode 100644 index 0000000000000000000000000000000000000000..f5a9e0c0c47ce2969affb08af684094da1850a58 GIT binary patch literal 3590 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 literal 0 HcmV?d00001 diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..d419587 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,17 @@ +.. This work is licensed under a Creative Commons Attribution 4.0 International License. +.. SPDX-License-Identifier: CC-BY-4.0 +.. Copyright (C) 2021 Nordix + +Non-RT RIC +========== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + ./overview.rst + ./developer-guide.rst + ./api-docs.rst + ./release-notes.rst + +* :ref:`search` diff --git a/docs/overview.rst b/docs/overview.rst new file mode 100644 index 0000000..a45820d --- /dev/null +++ b/docs/overview.rst @@ -0,0 +1,15 @@ +.. This work is licensed under a Creative Commons Attribution 4.0 International License. +.. SPDX-License-Identifier: CC-BY-4.0 +.. Copyright (C) 2021 Nordix + + +DMaaP Adapter +~~~~~~~~~~~~~ + +Configurable mediator to take information from DMaaP and Kafka and present it as a coordinated Information Producer. + +This adapter is a generic information producer, which register itself as an information producer of defined information types in Information Coordination Service (ICS). +The information types are defined in a configuration file. +Information jobs defined using ICS then allow information consumers to retrieve data from DMaaP MR or Kafka topics (accessing the ICS API). + +The adapter is implemented in Java Spring (DMaaP Adapter Service). diff --git a/docs/release-notes.rst b/docs/release-notes.rst new file mode 100644 index 0000000..dd5e0f4 --- /dev/null +++ b/docs/release-notes.rst @@ -0,0 +1,70 @@ +.. This work is licensed under a Creative Commons Attribution 4.0 International License. +.. http://creativecommons.org/licenses/by/4.0 +.. Copyright (C) 2021 Nordix + +============= +Release-Notes +============= + + +This document provides the release notes for the release of the DMaaP Adapter. + +Version history DMaaP Adater +============================ + ++------------+----------+------------------+-----------------+ +| **Date** | **Ver.** | **Author** | **Comment** | +| | | | | ++------------+----------+------------------+-----------------+ +| 2021-12-13 | 1.0.0 | Henrik Andersson | E Release | +| | | | Initial version | ++------------+----------+------------------+-----------------+ +| 2022-02-07 | 1.0.1 | Henrik Andersson | E Maintenance | +| | | | Release | +| | | | Non root user | +| | | | in Docker | +| | | | Bugfixes | ++------------+----------+------------------+-----------------+ + + +Release Data +============ + +E Release +--------- ++-----------------------------+---------------------------------------------------+ +| **Project** | Non-RT RIC | +| | | ++-----------------------------+---------------------------------------------------+ +| **Repo/commit-ID** | nonrtric/b472c167413a55a42fc7bfa08d2138f967a204fb | +| | | ++-----------------------------+---------------------------------------------------+ +| **Release designation** | E | +| | | ++-----------------------------+---------------------------------------------------+ +| **Release date** | 2021-12-13 | +| | | ++-----------------------------+---------------------------------------------------+ +| **Purpose of the delivery** | Initial release. | +| | | ++-----------------------------+---------------------------------------------------+ + +E Maintenance Release +--------------------- ++-----------------------------+---------------------------------------------------+ +| **Project** | Non-RT RIC | +| | | ++-----------------------------+---------------------------------------------------+ +| **Repo/commit-ID** | nonrtric/4df1f9ca4cd1ebc21e0c5ea57bcb0b7ef096d067 | +| | | ++-----------------------------+---------------------------------------------------+ +| **Release designation** | E | +| | | ++-----------------------------+---------------------------------------------------+ +| **Release date** | 2022-02-09 | +| | | ++-----------------------------+---------------------------------------------------+ +| **Purpose of the delivery** | Improvements and bug fixes | +| | | ++-----------------------------+---------------------------------------------------+ + diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt new file mode 100644 index 0000000..692a79f --- /dev/null +++ b/docs/requirements-docs.txt @@ -0,0 +1,12 @@ +tox +Sphinx +doc8 +docutils +setuptools +six +sphinx_rtd_theme +sphinxcontrib-needs +sphinxcontrib-swaggerdoc +sphinx_bootstrap_theme +sphinxcontrib-redoc +lfdocs-conf \ No newline at end of file diff --git a/eclipse-formatter.xml b/eclipse-formatter.xml new file mode 100644 index 0000000..b2e86eb --- /dev/null +++ b/eclipse-formatter.xml @@ -0,0 +1,362 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..18fde28 --- /dev/null +++ b/pom.xml @@ -0,0 +1,364 @@ + + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.5.8 + + + org.o-ran-sc.nonrtric + nonrtric-plt-dmaapadapter + 1.1.0-SNAPSHOT + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + onap-releases + onap-releases + https://nexus.onap.org/content/repositories/releases/ + + + + 11 + 3.0.0 + 2.8.2 + 2.1.6 + 20211205 + 3.8.0 + 2.12.2 + 1.24.3 + 3.0.11 + 0.30.0 + 3.7.0.1746 + 0.8.5 + true + + + + org.springdoc + springdoc-openapi-ui + 1.6.3 + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework + spring-webflux + + + io.swagger.core.v3 + swagger-jaxrs2 + ${swagger.version} + + + io.swagger.core.v3 + swagger-jaxrs2-servlet-initializer + ${swagger.version} + + + org.immutables + value + ${immutable.version} + provided + + + org.immutables + gson + ${immutable.version} + + + org.json + json + ${json.version} + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-starter-actuator + + + + io.springfox + springfox-swagger2 + ${springfox.version} + + + io.springfox + springfox-swagger-ui + ${springfox.version} + + + + org.springframework.boot + spring-boot-devtools + true + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.awaitility + awaitility + test + + + io.projectreactor + reactor-test + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.mockito + mockito-junit-jupiter + test + + + org.mockito + mockito-core + test + + + com.squareup.okhttp3 + mockwebserver + test + + + io.projectreactor.kafka + reactor-kafka + 1.3.9 + + + com.google.guava + guava + 31.0.1-jre + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + net.revelc.code.formatter + formatter-maven-plugin + ${formatter-maven-plugin.version} + + ${project.basedir}/eclipse-formatter.xml + + + + + com.diffplug.spotless + spotless-maven-plugin + ${spotless-maven-plugin.version} + + + + + com,java,javax,org + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + maven-failsafe-plugin + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-source + generate-sources + + add-source + + + + ${project.build.directory}/generated-sources/annotations/ + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + default-prepare-agent + + prepare-agent + + + + default-report + prepare-package + + report + + + + + + io.swagger.codegen.v3 + swagger-codegen-maven-plugin + ${swagger-codegen-maven-plugin.version} + + + test + + generate + + + ${project.basedir}/api/api.json + openapi-yaml + ${project.basedir}/api + + api.yaml + + + + + + + io.fabric8 + docker-maven-plugin + ${docker-maven-plugin} + false + + + generate-nonrtric-plt-dmaapadapter-image + package + + build + + + ${env.CONTAINER_PULL_REGISTRY} + + + o-ran-sc/nonrtric-nonrtric-plt-dmaapadapter:${project.version} + + try + ${basedir} + Dockerfile + + ${project.build.finalName}.jar + + + ${project.version} + + + + + + + + push-nonrtric-plt-dmaapadapter-image + + build + push + + + ${env.CONTAINER_PULL_REGISTRY} + ${env.CONTAINER_PUSH_REGISTRY} + + + o-ran-sc/nonrtric-nonrtric-plt-dmaapadapter:${project.version} + + ${basedir} + Dockerfile + + ${project.build.finalName}.jar + + + ${project.version} + latest + + + + + + + + + + + org.sonarsource.scanner.maven + sonar-maven-plugin + ${sonar-maven-plugin.version} + + + + + JIRA + https://jira.o-ran-sc.org/ + + diff --git a/src/main/java/org/oran/dmaapadapter/Application.java b/src/main/java/org/oran/dmaapadapter/Application.java new file mode 100644 index 0000000..2058202 --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/Application.java @@ -0,0 +1,78 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter; + +import java.io.File; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; + +@SpringBootApplication +@EnableConfigurationProperties +@EnableScheduling +public class Application { + + private static final Logger logger = LoggerFactory.getLogger(Application.class); + + @Value("${app.configuration-filepath}") + private String localConfigurationFilePath; + + private long configFileLastModification = 0; + private static ConfigurableApplicationContext applicationContext; + + public static void main(String[] args) { + applicationContext = SpringApplication.run(Application.class); + } + + @Scheduled(fixedRate = 10 * 1000) + public void checkConfigFileChanges() { + long timestamp = new File(localConfigurationFilePath).lastModified(); + if (configFileLastModification != 0 && timestamp != configFileLastModification) { + logger.info("Restarting due to change in the file {}", localConfigurationFilePath); + restartApplication(); + } + configFileLastModification = timestamp; + } + + private static void restartApplication() { + if (applicationContext == null) { + logger.info("Cannot restart in unittest"); + return; + } + ApplicationArguments args = applicationContext.getBean(ApplicationArguments.class); + + Thread thread = new Thread(() -> { + applicationContext.close(); + applicationContext = SpringApplication.run(Application.class, args.getSourceArgs()); + }); + + thread.setDaemon(false); + thread.start(); + } +} diff --git a/src/main/java/org/oran/dmaapadapter/BeanFactory.java b/src/main/java/org/oran/dmaapadapter/BeanFactory.java new file mode 100644 index 0000000..d98a8c3 --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/BeanFactory.java @@ -0,0 +1,70 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter; + +import java.util.Collection; + +import org.apache.catalina.connector.Connector; +import org.oran.dmaapadapter.configuration.ApplicationConfig; +import org.oran.dmaapadapter.repository.InfoType; +import org.oran.dmaapadapter.repository.InfoTypes; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.servlet.server.ServletWebServerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class BeanFactory { + + @Value("${server.http-port}") + private int httpPort = 0; + + @Bean + public ApplicationConfig getApplicationConfig() { + return new ApplicationConfig(); + } + + @Bean + public InfoTypes types(@Autowired ApplicationConfig appConfig) { + Collection types = appConfig.getTypes(); + return new InfoTypes(types); + } + + @Bean + public ServletWebServerFactory servletContainer() { + TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory(); + if (httpPort > 0) { + tomcat.addAdditionalTomcatConnectors(getHttpConnector(httpPort)); + } + return tomcat; + } + + private static Connector getHttpConnector(int httpPort) { + Connector connector = new Connector(TomcatServletWebServerFactory.DEFAULT_PROTOCOL); + connector.setScheme("http"); + connector.setPort(httpPort); + connector.setSecure(false); + return connector; + } + +} diff --git a/src/main/java/org/oran/dmaapadapter/SwaggerConfig.java b/src/main/java/org/oran/dmaapadapter/SwaggerConfig.java new file mode 100644 index 0000000..6128d2e --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/SwaggerConfig.java @@ -0,0 +1,43 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.info.License; + +/** + * Swagger configuration class that uses swagger documentation type and scans + * all the controllers. To access the swagger gui go to + * http://ip:port/swagger-ui.html + */ +@OpenAPIDefinition( // + info = @Info(title = SwaggerConfig.API_TITLE, // + version = "1.0", // + description = SwaggerConfig.DESCRIPTION, // + license = @License(name = "Copyright (C) 2021 Nordix Foundation. Licensed under the Apache License.", + url = "http://www.apache.org/licenses/LICENSE-2.0"))) +public class SwaggerConfig { + private SwaggerConfig() {} + + static final String API_TITLE = "Generic Dmaap and Kafka Information Producer"; + static final String DESCRIPTION = "Reads data from DMaaP and Kafka and posts it further to information consumers"; +} diff --git a/src/main/java/org/oran/dmaapadapter/clients/AsyncRestClient.java b/src/main/java/org/oran/dmaapadapter/clients/AsyncRestClient.java new file mode 100644 index 0000000..746fdd7 --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/clients/AsyncRestClient.java @@ -0,0 +1,221 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.clients; + +import io.netty.channel.ChannelOption; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; + +import java.lang.invoke.MethodHandles; +import java.util.concurrent.atomic.AtomicInteger; + +import org.oran.dmaapadapter.configuration.WebClientConfig.HttpProxyConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.lang.Nullable; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClient.RequestHeadersSpec; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; +import reactor.netty.transport.ProxyProvider; + +/** + * Generic reactive REST client. + */ +@SuppressWarnings("java:S4449") // @Add Nullable to third party api +public class AsyncRestClient { + + private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + private WebClient webClient = null; + private final String baseUrl; + private static final AtomicInteger sequenceNumber = new AtomicInteger(); + private final SslContext sslContext; + private final HttpProxyConfig httpProxyConfig; + + public AsyncRestClient(String baseUrl, @Nullable SslContext sslContext, @Nullable HttpProxyConfig httpProxyConfig) { + this.baseUrl = baseUrl; + this.sslContext = sslContext; + this.httpProxyConfig = httpProxyConfig; + } + + public Mono> postForEntity(String uri, @Nullable String body, + @Nullable MediaType contentType) { + Object traceTag = createTraceTag(); + logger.debug("{} POST uri = '{}{}''", traceTag, baseUrl, uri); + logger.trace("{} POST body: {}", traceTag, body); + Mono bodyProducer = body != null ? Mono.just(body) : Mono.empty(); + + RequestHeadersSpec request = getWebClient() // + .post() // + .uri(uri) // + .contentType(contentType) // + .body(bodyProducer, String.class); + return retrieve(traceTag, request); + } + + public Mono post(String uri, @Nullable String body, @Nullable MediaType contentType) { + return postForEntity(uri, body, contentType) // + .map(this::toBody); + } + + public Mono postWithAuthHeader(String uri, String body, String username, String password, + @Nullable MediaType mediaType) { + Object traceTag = createTraceTag(); + logger.debug("{} POST (auth) uri = '{}{}''", traceTag, baseUrl, uri); + logger.trace("{} POST body: {}", traceTag, body); + + RequestHeadersSpec request = getWebClient() // + .post() // + .uri(uri) // + .headers(headers -> headers.setBasicAuth(username, password)) // + .contentType(mediaType) // + .bodyValue(body); + return retrieve(traceTag, request) // + .map(this::toBody); + } + + public Mono> putForEntity(String uri, String body) { + Object traceTag = createTraceTag(); + logger.debug("{} PUT uri = '{}{}''", traceTag, baseUrl, uri); + logger.trace("{} PUT body: {}", traceTag, body); + + RequestHeadersSpec request = getWebClient() // + .put() // + .uri(uri) // + .contentType(MediaType.APPLICATION_JSON) // + .bodyValue(body); + return retrieve(traceTag, request); + } + + public Mono put(String uri, String body) { + return putForEntity(uri, body) // + .map(this::toBody); + } + + public Mono> getForEntity(String uri) { + Object traceTag = createTraceTag(); + logger.debug("{} GET uri = '{}{}''", traceTag, baseUrl, uri); + RequestHeadersSpec request = getWebClient().get().uri(uri); + return retrieve(traceTag, request); + } + + public Mono get(String uri) { + return getForEntity(uri) // + .map(this::toBody); + } + + public Mono> deleteForEntity(String uri) { + Object traceTag = createTraceTag(); + logger.debug("{} DELETE uri = '{}{}''", traceTag, baseUrl, uri); + RequestHeadersSpec request = getWebClient().delete().uri(uri); + return retrieve(traceTag, request); + } + + public Mono delete(String uri) { + return deleteForEntity(uri) // + .map(this::toBody); + } + + private Mono> retrieve(Object traceTag, RequestHeadersSpec request) { + final Class clazz = String.class; + return request.retrieve() // + .toEntity(clazz) // + .doOnNext(entity -> logReceivedData(traceTag, entity)) // + .doOnError(throwable -> onHttpError(traceTag, throwable)); + } + + private void logReceivedData(Object traceTag, ResponseEntity entity) { + logger.trace("{} Received: {} {}", traceTag, entity.getBody(), entity.getHeaders().getContentType()); + } + + private static Object createTraceTag() { + return sequenceNumber.incrementAndGet(); + } + + private void onHttpError(Object traceTag, Throwable t) { + if (t instanceof WebClientResponseException) { + WebClientResponseException exception = (WebClientResponseException) t; + logger.debug("{} HTTP error status = '{}', body '{}'", traceTag, exception.getStatusCode(), + exception.getResponseBodyAsString()); + } else { + logger.debug("{} HTTP error {}", traceTag, t.getMessage()); + } + } + + private String toBody(ResponseEntity entity) { + if (entity.getBody() == null) { + return ""; + } else { + return entity.getBody(); + } + } + + private boolean isHttpProxyConfigured() { + return httpProxyConfig != null && httpProxyConfig.httpProxyPort() > 0 + && !httpProxyConfig.httpProxyHost().isEmpty(); + } + + private HttpClient buildHttpClient() { + HttpClient httpClient = HttpClient.create() // + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000) // + .doOnConnected(connection -> { + connection.addHandlerLast(new ReadTimeoutHandler(30)); + connection.addHandlerLast(new WriteTimeoutHandler(30)); + }); + + if (this.sslContext != null) { + httpClient = httpClient.secure(ssl -> ssl.sslContext(sslContext)); + } + + if (isHttpProxyConfigured()) { + httpClient = httpClient.proxy(proxy -> proxy.type(ProxyProvider.Proxy.HTTP) + .host(httpProxyConfig.httpProxyHost()).port(httpProxyConfig.httpProxyPort())); + } + return httpClient; + } + + private WebClient buildWebClient(String baseUrl) { + final HttpClient httpClient = buildHttpClient(); + ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder() // + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(-1)) // + .build(); + return WebClient.builder() // + .clientConnector(new ReactorClientHttpConnector(httpClient)) // + .baseUrl(baseUrl) // + .exchangeStrategies(exchangeStrategies) // + .build(); + } + + private WebClient getWebClient() { + if (this.webClient == null) { + this.webClient = buildWebClient(baseUrl); + } + return this.webClient; + } + +} diff --git a/src/main/java/org/oran/dmaapadapter/clients/AsyncRestClientFactory.java b/src/main/java/org/oran/dmaapadapter/clients/AsyncRestClientFactory.java new file mode 100644 index 0000000..18e5900 --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/clients/AsyncRestClientFactory.java @@ -0,0 +1,193 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.clients; + +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.util.InsecureTrustManagerFactory; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.invoke.MethodHandles; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import javax.net.ssl.KeyManagerFactory; + +import org.oran.dmaapadapter.configuration.WebClientConfig; +import org.oran.dmaapadapter.configuration.WebClientConfig.HttpProxyConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.ResourceUtils; + +/** + * Factory for a generic reactive REST client. + */ +@SuppressWarnings("squid:S2629") // Invoke method(s) only conditionally +public class AsyncRestClientFactory { + private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private final SslContextFactory sslContextFactory; + private final HttpProxyConfig httpProxyConfig; + + public AsyncRestClientFactory(WebClientConfig clientConfig) { + if (clientConfig != null) { + this.sslContextFactory = new CachingSslContextFactory(clientConfig); + this.httpProxyConfig = clientConfig.httpProxyConfig(); + } else { + logger.warn("No configuration for web client defined, HTTPS will not work"); + this.sslContextFactory = null; + this.httpProxyConfig = null; + } + } + + public AsyncRestClient createRestClientNoHttpProxy(String baseUrl) { + return createRestClient(baseUrl, false); + } + + public AsyncRestClient createRestClientUseHttpProxy(String baseUrl) { + return createRestClient(baseUrl, true); + } + + private AsyncRestClient createRestClient(String baseUrl, boolean useHttpProxy) { + if (this.sslContextFactory != null) { + try { + return new AsyncRestClient(baseUrl, this.sslContextFactory.createSslContext(), + useHttpProxy ? httpProxyConfig : null); + } catch (Exception e) { + String exceptionString = e.toString(); + logger.error("Could not init SSL context, reason: {}", exceptionString); + } + } + return new AsyncRestClient(baseUrl, null, httpProxyConfig); + } + + private class SslContextFactory { + private final WebClientConfig clientConfig; + + public SslContextFactory(WebClientConfig clientConfig) { + this.clientConfig = clientConfig; + } + + public SslContext createSslContext() throws UnrecoverableKeyException, NoSuchAlgorithmException, + CertificateException, KeyStoreException, IOException { + return this.createSslContext(createKeyManager()); + } + + private SslContext createSslContext(KeyManagerFactory keyManager) + throws NoSuchAlgorithmException, CertificateException, KeyStoreException, IOException { + if (this.clientConfig.isTrustStoreUsed()) { + return createSslContextRejectingUntrustedPeers(this.clientConfig.trustStore(), + this.clientConfig.trustStorePassword(), keyManager); + } else { + // Trust anyone + return SslContextBuilder.forClient() // + .keyManager(keyManager) // + .trustManager(InsecureTrustManagerFactory.INSTANCE) // + .build(); + } + } + + private SslContext createSslContextRejectingUntrustedPeers(String trustStorePath, String trustStorePass, + KeyManagerFactory keyManager) + throws NoSuchAlgorithmException, CertificateException, IOException, KeyStoreException { + + final KeyStore trustStore = getTrustStore(trustStorePath, trustStorePass); + List certificateList = Collections.list(trustStore.aliases()).stream() // + .filter(alias -> isCertificateEntry(trustStore, alias)) // + .map(alias -> getCertificate(trustStore, alias)) // + .collect(Collectors.toList()); + final X509Certificate[] certificates = certificateList.toArray(new X509Certificate[certificateList.size()]); + + return SslContextBuilder.forClient() // + .keyManager(keyManager) // + .trustManager(certificates) // + .build(); + } + + private boolean isCertificateEntry(KeyStore trustStore, String alias) { + try { + return trustStore.isCertificateEntry(alias); + } catch (KeyStoreException e) { + logger.error("Error reading truststore {}", e.getMessage()); + return false; + } + } + + private Certificate getCertificate(KeyStore trustStore, String alias) { + try { + return trustStore.getCertificate(alias); + } catch (KeyStoreException e) { + logger.error("Error reading truststore {}", e.getMessage()); + return null; + } + } + + private KeyManagerFactory createKeyManager() throws NoSuchAlgorithmException, CertificateException, IOException, + UnrecoverableKeyException, KeyStoreException { + final KeyManagerFactory keyManager = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + final KeyStore keyStore = KeyStore.getInstance(this.clientConfig.keyStoreType()); + final String keyStoreFile = this.clientConfig.keyStore(); + final String keyStorePassword = this.clientConfig.keyStorePassword(); + final String keyPassword = this.clientConfig.keyPassword(); + try (final InputStream inputStream = new FileInputStream(keyStoreFile)) { + keyStore.load(inputStream, keyStorePassword.toCharArray()); + } + keyManager.init(keyStore, keyPassword.toCharArray()); + return keyManager; + } + + private synchronized KeyStore getTrustStore(String trustStorePath, String trustStorePass) + throws NoSuchAlgorithmException, CertificateException, IOException, KeyStoreException { + + KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType()); + store.load(new FileInputStream(ResourceUtils.getFile(trustStorePath)), trustStorePass.toCharArray()); + return store; + } + } + + public class CachingSslContextFactory extends SslContextFactory { + private SslContext cachedContext = null; + + public CachingSslContextFactory(WebClientConfig clientConfig) { + super(clientConfig); + } + + @Override + public SslContext createSslContext() throws UnrecoverableKeyException, NoSuchAlgorithmException, + CertificateException, KeyStoreException, IOException { + if (this.cachedContext == null) { + this.cachedContext = super.createSslContext(); + } + return this.cachedContext; + + } + } +} diff --git a/src/main/java/org/oran/dmaapadapter/configuration/ApplicationConfig.java b/src/main/java/org/oran/dmaapadapter/configuration/ApplicationConfig.java new file mode 100644 index 0000000..3ea64e7 --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/configuration/ApplicationConfig.java @@ -0,0 +1,139 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.configuration; + +import java.lang.invoke.MethodHandles; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; + +import lombok.Getter; +import lombok.Setter; + +import org.oran.dmaapadapter.configuration.WebClientConfig.HttpProxyConfig; +import org.oran.dmaapadapter.repository.InfoType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@EnableConfigurationProperties +public class ApplicationConfig { + + private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + @Getter + @Value("${app.configuration-filepath}") + private String localConfigurationFilePath; + + @Value("${server.ssl.key-store-type}") + private String sslKeyStoreType = ""; + + @Value("${server.ssl.key-store-password}") + private String sslKeyStorePassword = ""; + + @Value("${server.ssl.key-store}") + private String sslKeyStore = ""; + + @Value("${server.ssl.key-password}") + private String sslKeyPassword = ""; + + @Value("${app.webclient.trust-store-used}") + private boolean sslTrustStoreUsed = false; + + @Value("${app.webclient.trust-store-password}") + private String sslTrustStorePassword = ""; + + @Value("${app.webclient.trust-store}") + private String sslTrustStore = ""; + + @Value("${app.webclient.http.proxy-host:\"\"}") + private String httpProxyHost = ""; + + @Value("${app.webclient.http.proxy-port:0}") + private int httpProxyPort = 0; + + @Getter + @Setter + @Value("${server.port}") + private int localServerHttpPort; + + @Getter + @Value("${app.ics-base-url}") + private String icsBaseUrl; + + @Getter + @Value("${app.dmaap-adapter-base-url}") + private String selfUrl; + + @Getter + @Value("${app.dmaap-base-url}") + private String dmaapBaseUrl; + + @Getter + @Value("${app.kafka.bootstrap-servers:}") + private String kafkaBootStrapServers; + + private WebClientConfig webClientConfig = null; + + public WebClientConfig getWebClientConfig() { + if (this.webClientConfig == null) { + HttpProxyConfig httpProxyConfig = ImmutableHttpProxyConfig.builder() // + .httpProxyHost(this.httpProxyHost) // + .httpProxyPort(this.httpProxyPort) // + .build(); + + this.webClientConfig = ImmutableWebClientConfig.builder() // + .keyStoreType(this.sslKeyStoreType) // + .keyStorePassword(this.sslKeyStorePassword) // + .keyStore(this.sslKeyStore) // + .keyPassword(this.sslKeyPassword) // + .isTrustStoreUsed(this.sslTrustStoreUsed) // + .trustStore(this.sslTrustStore) // + .trustStorePassword(this.sslTrustStorePassword) // + .httpProxyConfig(httpProxyConfig) // + .build(); + } + return this.webClientConfig; + } + + // Adapter to parse the json format of the configuration file. + static class ConfigFile { + Collection types; + } + + public Collection getTypes() { + com.google.gson.Gson gson = new com.google.gson.GsonBuilder().create(); + + try { + String configJson = Files.readString(Path.of(getLocalConfigurationFilePath()), Charset.defaultCharset()); + ConfigFile configData = gson.fromJson(configJson, ConfigFile.class); + return configData.types; + } catch (Exception e) { + logger.error("Could not load configuration file {}", getLocalConfigurationFilePath()); + return Collections.emptyList(); + } + + } + +} diff --git a/src/main/java/org/oran/dmaapadapter/configuration/WebClientConfig.java b/src/main/java/org/oran/dmaapadapter/configuration/WebClientConfig.java new file mode 100644 index 0000000..e65fdb9 --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/configuration/WebClientConfig.java @@ -0,0 +1,54 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.configuration; + +import org.immutables.value.Value; + +@Value.Immutable +@Value.Style(redactedMask = "####") +public interface WebClientConfig { + public String keyStoreType(); + + @Value.Redacted + public String keyStorePassword(); + + public String keyStore(); + + @Value.Redacted + public String keyPassword(); + + public boolean isTrustStoreUsed(); + + @Value.Redacted + public String trustStorePassword(); + + public String trustStore(); + + @Value.Immutable + public interface HttpProxyConfig { + public String httpProxyHost(); + + public int httpProxyPort(); + } + + public HttpProxyConfig httpProxyConfig(); + +} diff --git a/src/main/java/org/oran/dmaapadapter/controllers/ErrorResponse.java b/src/main/java/org/oran/dmaapadapter/controllers/ErrorResponse.java new file mode 100644 index 0000000..39f62fb --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/controllers/ErrorResponse.java @@ -0,0 +1,112 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.controllers; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.annotations.SerializedName; + +import io.swagger.v3.oas.annotations.media.Schema; + +import org.oran.dmaapadapter.exceptions.ServiceException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import reactor.core.publisher.Mono; + +public class ErrorResponse { + private static Gson gson = new GsonBuilder() // + .create(); // + + // Returned as body for all failed REST calls + @Schema(name = "error_information", description = "Problem as defined in https://tools.ietf.org/html/rfc7807") + public static class ErrorInfo { + @SerializedName("type") + private String type = "about:blank"; + + @SerializedName("title") + private String title = null; + + @SerializedName("status") + private final Integer status; + + @SerializedName("detail") + private String detail = null; + + @SerializedName("instance") + private String instance = null; + + public ErrorInfo(String detail, Integer status) { + this.detail = detail; + this.status = status; + } + + @Schema(example = "503", + description = "The HTTP status code generated by the origin server for this occurrence of the problem. ") + public Integer getStatus() { + return status; + } + + @Schema(example = "Policy type not found", + description = " A human-readable explanation specific to this occurrence of the problem.") + public String getDetail() { + return this.detail; + } + + } + + @Schema(name = "message", description = "message") + public final String message; + + ErrorResponse(String message) { + this.message = message; + } + + static Mono> createMono(String text, HttpStatus code) { + return Mono.just(create(text, code)); + } + + static Mono> createMono(Exception e, HttpStatus code) { + return createMono(e.toString(), code); + } + + public static ResponseEntity create(String text, HttpStatus code) { + ErrorInfo p = new ErrorInfo(text, code.value()); + String json = gson.toJson(p); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PROBLEM_JSON); + return new ResponseEntity<>(json, headers, code); + } + + public static ResponseEntity create(Throwable e, HttpStatus code) { + if (e instanceof RuntimeException) { + code = HttpStatus.INTERNAL_SERVER_ERROR; + } else if (e instanceof ServiceException) { + ServiceException se = (ServiceException) e; + if (se.getHttpStatus() != null) { + code = se.getHttpStatus(); + } + } + return create(e.toString(), code); + } + +} diff --git a/src/main/java/org/oran/dmaapadapter/controllers/ProducerCallbacksController.java b/src/main/java/org/oran/dmaapadapter/controllers/ProducerCallbacksController.java new file mode 100644 index 0000000..94f9f8d --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/controllers/ProducerCallbacksController.java @@ -0,0 +1,148 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.controllers; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.ArrayList; +import java.util.Collection; + +import org.oran.dmaapadapter.exceptions.ServiceException; +import org.oran.dmaapadapter.r1.ProducerJobInfo; +import org.oran.dmaapadapter.repository.InfoTypes; +import org.oran.dmaapadapter.repository.Job; +import org.oran.dmaapadapter.repository.Jobs; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.RestController; + +@RestController("ConfigurationControllerV2") +@Tag(name = ProducerCallbacksController.API_NAME) +public class ProducerCallbacksController { + private static final Logger logger = LoggerFactory.getLogger(ProducerCallbacksController.class); + + public static final String API_NAME = "Producer job control API"; + public static final String API_DESCRIPTION = ""; + public static final String JOB_URL = "/generic_dataproducer/info_job"; + public static final String SUPERVISION_URL = "/generic_dataproducer/health_check"; + private static Gson gson = new GsonBuilder().create(); + private final Jobs jobs; + private final InfoTypes types; + + public ProducerCallbacksController(@Autowired Jobs jobs, @Autowired InfoTypes types) { + this.jobs = jobs; + this.types = types; + } + + @PostMapping(path = JOB_URL, produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Callback for Information Job creation/modification", + description = "The call is invoked to activate or to modify a data subscription. The endpoint is provided by the Information Producer.") + @ApiResponses(value = { // + @ApiResponse(responseCode = "200", description = "OK", // + content = @Content(schema = @Schema(implementation = VoidResponse.class))), // + @ApiResponse(responseCode = "404", description = "Information type is not found", // + content = @Content(schema = @Schema(implementation = ErrorResponse.ErrorInfo.class))), // + @ApiResponse(responseCode = "400", description = "Other error in the request", // + content = @Content(schema = @Schema(implementation = ErrorResponse.ErrorInfo.class))) // + }) + public ResponseEntity jobCreatedCallback( // + @RequestBody String body) { + try { + ProducerJobInfo request = gson.fromJson(body, ProducerJobInfo.class); + logger.debug("Job started callback {}", request.id); + this.jobs.addJob(request.id, request.targetUri, types.getType(request.typeId), request.owner, + request.lastUpdated, toJobParameters(request.jobData)); + return new ResponseEntity<>(HttpStatus.OK); + } catch (ServiceException e) { + logger.warn("jobCreatedCallback failed: {}", e.getMessage()); + return ErrorResponse.create(e, e.getHttpStatus()); + } catch (Exception e) { + logger.warn("jobCreatedCallback failed: {}", e.getMessage()); + return ErrorResponse.create(e, HttpStatus.BAD_REQUEST); + } + } + + private Job.Parameters toJobParameters(Object jobData) { + String json = gson.toJson(jobData); + return gson.fromJson(json, Job.Parameters.class); + } + + @GetMapping(path = JOB_URL, produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Get all jobs", description = "Returns all info jobs, can be used for trouble shooting") + @ApiResponse(responseCode = "200", // + description = "Information jobs", // + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ProducerJobInfo.class)))) // + public ResponseEntity getJobs() { + + Collection producerJobs = new ArrayList<>(); + for (Job j : this.jobs.getAll()) { + producerJobs.add(new ProducerJobInfo(null, j.getId(), j.getType().getId(), j.getCallbackUrl(), j.getOwner(), + j.getLastUpdated())); + } + return new ResponseEntity<>(gson.toJson(producerJobs), HttpStatus.OK); + } + + @DeleteMapping(path = JOB_URL + "/{infoJobId}", produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Callback for Information Job deletion", + description = "The call is invoked to terminate a data subscription. The endpoint is provided by the Information Producer.") + @ApiResponses(value = { // + @ApiResponse(responseCode = "200", description = "OK", // + content = @Content(schema = @Schema(implementation = VoidResponse.class))) // + }) + public ResponseEntity jobDeletedCallback( // + @PathVariable("infoJobId") String infoJobId) { + + logger.debug("Job deleted callback {}", infoJobId); + this.jobs.remove(infoJobId); + return new ResponseEntity<>(HttpStatus.OK); + } + + @GetMapping(path = SUPERVISION_URL, produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Producer supervision", + description = "The endpoint is provided by the Information Producer and is used for supervision of the producer.") + @ApiResponses(value = { // + @ApiResponse(responseCode = "200", description = "The producer is OK", // + content = @Content(schema = @Schema(implementation = String.class))) // + }) + public ResponseEntity producerSupervision() { + logger.debug("Producer supervision"); + return new ResponseEntity<>(HttpStatus.OK); + } + +} diff --git a/src/main/java/org/oran/dmaapadapter/controllers/VoidResponse.java b/src/main/java/org/oran/dmaapadapter/controllers/VoidResponse.java new file mode 100644 index 0000000..b7bba5f --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/controllers/VoidResponse.java @@ -0,0 +1,31 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.controllers; + +import io.swagger.v3.oas.annotations.media.Schema; + +import org.immutables.gson.Gson; + +@Gson.TypeAdapters +@Schema(name = "void", description = "Void/empty") +public class VoidResponse { + private VoidResponse() {} +} diff --git a/src/main/java/org/oran/dmaapadapter/exceptions/ServiceException.java b/src/main/java/org/oran/dmaapadapter/exceptions/ServiceException.java new file mode 100644 index 0000000..b30e28e --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/exceptions/ServiceException.java @@ -0,0 +1,39 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.exceptions; + +import lombok.Getter; + +import org.springframework.http.HttpStatus; + +public class ServiceException extends Exception { + + private static final long serialVersionUID = 1L; + + @Getter + private final HttpStatus httpStatus; + + public ServiceException(String message, HttpStatus httpStatus) { + super(message); + this.httpStatus = httpStatus; + } + +} diff --git a/src/main/java/org/oran/dmaapadapter/r1/ConsumerJobInfo.java b/src/main/java/org/oran/dmaapadapter/r1/ConsumerJobInfo.java new file mode 100644 index 0000000..c1737db --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/r1/ConsumerJobInfo.java @@ -0,0 +1,71 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.r1; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.annotations.SerializedName; + +import io.swagger.v3.oas.annotations.media.Schema; + +import org.immutables.gson.Gson; + +@Gson.TypeAdapters +@Schema(name = "consumer_job", description = "Information for an Information Job") +public class ConsumerJobInfo { + + @Schema(name = "info_type_id", description = "Information type Idenitifier of the subscription job", + required = true) + @SerializedName("info_type_id") + @JsonProperty(value = "info_type_id", required = true) + public String infoTypeId = ""; + + @Schema(name = "job_owner", description = "Identity of the owner of the job", required = true) + @SerializedName("job_owner") + @JsonProperty(value = "job_owner", required = true) + public String owner = ""; + + @Schema(name = "job_definition", description = "Information type specific job data", required = true) + @SerializedName("job_definition") + @JsonProperty(value = "job_definition", required = true) + public Object jobDefinition; + + @Schema(name = "job_result_uri", description = "The target URI of the subscribed information", required = true) + @SerializedName("job_result_uri") + @JsonProperty(value = "job_result_uri", required = true) + public String jobResultUri = ""; + + @Schema(name = "status_notification_uri", + description = "The target of Information subscription job status notifications", required = false) + @SerializedName("status_notification_uri") + @JsonProperty(value = "status_notification_uri", required = false) + public String statusNotificationUri = ""; + + public ConsumerJobInfo() {} + + public ConsumerJobInfo(String infoTypeId, Object jobData, String owner, String targetUri, + String statusNotificationUri) { + this.infoTypeId = infoTypeId; + this.jobDefinition = jobData; + this.owner = owner; + this.jobResultUri = targetUri; + this.statusNotificationUri = statusNotificationUri; + } +} diff --git a/src/main/java/org/oran/dmaapadapter/r1/ProducerInfoTypeInfo.java b/src/main/java/org/oran/dmaapadapter/r1/ProducerInfoTypeInfo.java new file mode 100644 index 0000000..1bf5e47 --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/r1/ProducerInfoTypeInfo.java @@ -0,0 +1,52 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.r1; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.annotations.SerializedName; + +import io.swagger.v3.oas.annotations.media.Schema; + +import org.immutables.gson.Gson; + +@Gson.TypeAdapters +@Schema(name = "producer_info_type_info", description = "Information for an Information Type") +public class ProducerInfoTypeInfo { + + @Schema(name = "info_job_data_schema", description = "Json schema for the job data", required = true) + @SerializedName("info_job_data_schema") + @JsonProperty(value = "info_job_data_schema", required = true) + public Object jobDataSchema; + + @Schema(name = "info_type_information", description = "Type specific information for the information type", + required = true) + @SerializedName("info_type_information") + @JsonProperty(value = "info_type_information", required = true) + public Object typeSpecificInformation; + + public ProducerInfoTypeInfo(Object jobDataSchema, Object typeSpecificInformation) { + this.jobDataSchema = jobDataSchema; + this.typeSpecificInformation = typeSpecificInformation; + } + + public ProducerInfoTypeInfo() {} + +} diff --git a/src/main/java/org/oran/dmaapadapter/r1/ProducerJobInfo.java b/src/main/java/org/oran/dmaapadapter/r1/ProducerJobInfo.java new file mode 100644 index 0000000..d378825 --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/r1/ProducerJobInfo.java @@ -0,0 +1,77 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.r1; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.annotations.SerializedName; + +import io.swagger.v3.oas.annotations.media.Schema; + +import org.immutables.gson.Gson; + +@Gson.TypeAdapters +@Schema(name = "producer_info_job_request", + description = "The body of the Information Producer callbacks for Information Job creation and deletion") +public class ProducerJobInfo { + + @Schema(name = "info_job_identity", description = "Identity of the Information Job", required = true) + @SerializedName("info_job_identity") + @JsonProperty("info_job_identity") + public String id = ""; + + @Schema(name = "info_type_identity", description = "Type identity for the job") + @SerializedName("info_type_identity") + @JsonProperty("info_type_identity") + public String typeId = ""; + + @Schema(name = "info_job_data", description = "Json for the job data") + @SerializedName("info_job_data") + @JsonProperty("info_job_data") + public Object jobData; + + @Schema(name = "target_uri", description = "URI for the target of the produced Information") + @SerializedName("target_uri") + @JsonProperty("target_uri") + public String targetUri = ""; + + @Schema(name = "owner", description = "The owner of the job") + @SerializedName("owner") + @JsonProperty("owner") + public String owner = ""; + + @Schema(name = "last_updated", description = "The time when the job was last updated or created (ISO-8601)") + @SerializedName("last_updated") + @JsonProperty("last_updated") + public String lastUpdated = ""; + + public ProducerJobInfo(Object jobData, String id, String typeId, String targetUri, String owner, + String lastUpdated) { + this.id = id; + this.jobData = jobData; + this.typeId = typeId; + this.targetUri = targetUri; + this.owner = owner; + this.lastUpdated = lastUpdated; + } + + public ProducerJobInfo() {} + +} diff --git a/src/main/java/org/oran/dmaapadapter/r1/ProducerRegistrationInfo.java b/src/main/java/org/oran/dmaapadapter/r1/ProducerRegistrationInfo.java new file mode 100644 index 0000000..e54c152 --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/r1/ProducerRegistrationInfo.java @@ -0,0 +1,64 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.r1; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.annotations.SerializedName; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Collection; + +import lombok.Builder; + +import org.immutables.gson.Gson; + +@Builder +@Gson.TypeAdapters +@Schema(name = "producer_registration_info", description = "Information for an Information Producer") +public class ProducerRegistrationInfo { + + @Schema(name = "supported_info_types", description = "Supported Information Type IDs", required = true) + @SerializedName("supported_info_types") + @JsonProperty(value = "supported_info_types", required = true) + public Collection supportedTypeIds; + + @Schema(name = "info_job_callback_url", description = "callback for Information Job", required = true) + @SerializedName("info_job_callback_url") + @JsonProperty(value = "info_job_callback_url", required = true) + public String jobCallbackUrl; + + @Schema(name = "info_producer_supervision_callback_url", description = "callback for producer supervision", + required = true) + @SerializedName("info_producer_supervision_callback_url") + @JsonProperty(value = "info_producer_supervision_callback_url", required = true) + public String producerSupervisionCallbackUrl; + + public ProducerRegistrationInfo(Collection types, String jobCallbackUrl, + String producerSupervisionCallbackUrl) { + this.supportedTypeIds = types; + this.jobCallbackUrl = jobCallbackUrl; + this.producerSupervisionCallbackUrl = producerSupervisionCallbackUrl; + } + + public ProducerRegistrationInfo() {} + +} diff --git a/src/main/java/org/oran/dmaapadapter/repository/InfoType.java b/src/main/java/org/oran/dmaapadapter/repository/InfoType.java new file mode 100644 index 0000000..27b527d --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/repository/InfoType.java @@ -0,0 +1,56 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.repository; + +import lombok.Getter; + +import org.springframework.util.StringUtils; + +public class InfoType { + + @Getter + private final String id; + + @Getter + private final String dmaapTopicUrl; + + @Getter + private final boolean useHttpProxy; + + @Getter + private final String kafkaInputTopic; + + public InfoType(String id, String dmaapTopicUrl, boolean useHttpProxy, String kafkaInputTopic) { + this.id = id; + this.dmaapTopicUrl = dmaapTopicUrl; + this.useHttpProxy = useHttpProxy; + this.kafkaInputTopic = kafkaInputTopic; + } + + public boolean isKafkaTopicDefined() { + return StringUtils.hasLength(kafkaInputTopic); + } + + public boolean isDmaapTopicDefined() { + return StringUtils.hasLength(dmaapTopicUrl); + } + +} diff --git a/src/main/java/org/oran/dmaapadapter/repository/InfoTypes.java b/src/main/java/org/oran/dmaapadapter/repository/InfoTypes.java new file mode 100644 index 0000000..baa998b --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/repository/InfoTypes.java @@ -0,0 +1,80 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.repository; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Vector; + +import org.oran.dmaapadapter.exceptions.ServiceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; + +public class InfoTypes { + private static final Logger logger = LoggerFactory.getLogger(InfoTypes.class); + + private Map allTypes = new HashMap<>(); + + public InfoTypes(Collection types) { + for (InfoType type : types) { + put(type); + } + } + + public synchronized InfoType get(String id) { + return allTypes.get(id); + } + + public synchronized InfoType getType(String id) throws ServiceException { + InfoType type = allTypes.get(id); + if (type == null) { + throw new ServiceException("Could not find type: " + id, HttpStatus.NOT_FOUND); + } + return type; + } + + public static class ConfigFile { + Collection types; + } + + private synchronized void put(InfoType type) { + logger.debug("Put type: {}", type.getId()); + allTypes.put(type.getId(), type); + } + + public synchronized Iterable getAll() { + return new Vector<>(allTypes.values()); + } + + public synchronized Collection typeIds() { + return allTypes.keySet(); + } + + public synchronized int size() { + return allTypes.size(); + } + + public synchronized void clear() { + allTypes.clear(); + } +} diff --git a/src/main/java/org/oran/dmaapadapter/repository/Job.java b/src/main/java/org/oran/dmaapadapter/repository/Job.java new file mode 100644 index 0000000..5f7521c --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/repository/Job.java @@ -0,0 +1,127 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.repository; + +import java.time.Duration; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import lombok.Getter; + +import org.immutables.gson.Gson; +import org.oran.dmaapadapter.clients.AsyncRestClient; + +public class Job { + + @Gson.TypeAdapters + public static class Parameters { + @Getter + private String filter; + @Getter + private BufferTimeout bufferTimeout; + + private int maxConcurrency; + + public Parameters() {} + + public Parameters(String filter, BufferTimeout bufferTimeout, int maxConcurrency) { + this.filter = filter; + this.bufferTimeout = bufferTimeout; + this.maxConcurrency = maxConcurrency; + } + + public int getMaxConcurrency() { + return maxConcurrency == 0 ? 1 : maxConcurrency; + } + } + + @Gson.TypeAdapters + public static class BufferTimeout { + public BufferTimeout(int maxSize, long maxTimeMiliseconds) { + this.maxSize = maxSize; + this.maxTimeMiliseconds = maxTimeMiliseconds; + } + + public BufferTimeout() {} + + @Getter + private int maxSize; + + private long maxTimeMiliseconds; + + public Duration getMaxTime() { + return Duration.ofMillis(maxTimeMiliseconds); + } + } + + @Getter + private final String id; + + @Getter + private final String callbackUrl; + + @Getter + private final InfoType type; + + @Getter + private final String owner; + + @Getter + private final Parameters parameters; + + @Getter + private final String lastUpdated; + + private final Pattern jobDataFilter; + + @Getter + private final AsyncRestClient consumerRestClient; + + public Job(String id, String callbackUrl, InfoType type, String owner, String lastUpdated, Parameters parameters, + AsyncRestClient consumerRestClient) { + this.id = id; + this.callbackUrl = callbackUrl; + this.type = type; + this.owner = owner; + this.lastUpdated = lastUpdated; + this.parameters = parameters; + if (parameters != null && parameters.filter != null) { + jobDataFilter = Pattern.compile(parameters.filter); + } else { + jobDataFilter = null; + } + this.consumerRestClient = consumerRestClient; + } + + public boolean isFilterMatch(String data) { + if (jobDataFilter == null) { + return true; + } + Matcher matcher = jobDataFilter.matcher(data); + return matcher.find(); + } + + public boolean isBuffered() { + return parameters != null && parameters.bufferTimeout != null && parameters.bufferTimeout.maxSize > 0 + && parameters.bufferTimeout.maxTimeMiliseconds > 0; + } + +} diff --git a/src/main/java/org/oran/dmaapadapter/repository/Jobs.java b/src/main/java/org/oran/dmaapadapter/repository/Jobs.java new file mode 100644 index 0000000..ec33774 --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/repository/Jobs.java @@ -0,0 +1,130 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.repository; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Vector; + +import org.oran.dmaapadapter.clients.AsyncRestClient; +import org.oran.dmaapadapter.clients.AsyncRestClientFactory; +import org.oran.dmaapadapter.configuration.ApplicationConfig; +import org.oran.dmaapadapter.exceptions.ServiceException; +import org.oran.dmaapadapter.repository.Job.Parameters; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +@Component +public class Jobs { + public interface Observer { + void onJobbAdded(Job job); + + void onJobRemoved(Job job); + } + + private static final Logger logger = LoggerFactory.getLogger(Jobs.class); + + private Map allJobs = new HashMap<>(); + private MultiMap jobsByType = new MultiMap<>(); + private final AsyncRestClientFactory restclientFactory; + private final List observers = new ArrayList<>(); + + public Jobs(@Autowired ApplicationConfig applicationConfig) { + restclientFactory = new AsyncRestClientFactory(applicationConfig.getWebClientConfig()); + } + + public synchronized Job getJob(String id) throws ServiceException { + Job job = allJobs.get(id); + if (job == null) { + throw new ServiceException("Could not find job: " + id, HttpStatus.NOT_FOUND); + } + return job; + } + + public synchronized Job get(String id) { + return allJobs.get(id); + } + + public void addJob(String id, String callbackUrl, InfoType type, String owner, String lastUpdated, + Parameters parameters) { + AsyncRestClient consumerRestClient = type.isUseHttpProxy() // + ? restclientFactory.createRestClientUseHttpProxy(callbackUrl) // + : restclientFactory.createRestClientNoHttpProxy(callbackUrl); + Job job = new Job(id, callbackUrl, type, owner, lastUpdated, parameters, consumerRestClient); + this.put(job); + synchronized (observers) { + this.observers.forEach(obs -> obs.onJobbAdded(job)); + } + } + + public void addObserver(Observer obs) { + synchronized (observers) { + this.observers.add(obs); + } + } + + private synchronized void put(Job job) { + logger.debug("Put job: {}", job.getId()); + allJobs.put(job.getId(), job); + jobsByType.put(job.getType().getId(), job.getId(), job); + } + + public synchronized Iterable getAll() { + return new Vector<>(allJobs.values()); + } + + public synchronized Job remove(String id) { + Job job = allJobs.get(id); + if (job != null) { + remove(job); + } + return job; + } + + public void remove(Job job) { + synchronized (this) { + this.allJobs.remove(job.getId()); + jobsByType.remove(job.getType().getId(), job.getId()); + } + synchronized (observers) { + this.observers.forEach(obs -> obs.onJobRemoved(job)); + } + } + + public synchronized int size() { + return allJobs.size(); + } + + public synchronized Collection getJobsForType(InfoType type) { + return jobsByType.get(type.getId()); + } + + public synchronized void clear() { + allJobs.clear(); + jobsByType.clear(); + } +} diff --git a/src/main/java/org/oran/dmaapadapter/repository/MultiMap.java b/src/main/java/org/oran/dmaapadapter/repository/MultiMap.java new file mode 100644 index 0000000..f7cc14e --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/repository/MultiMap.java @@ -0,0 +1,78 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2019-2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.repository; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.Vector; + +/** + * A map, where each key can be bound to may values (where each value has an own + * ID) + */ +public class MultiMap { + + private final Map> map = new HashMap<>(); + + public void put(String key, String id, T value) { + this.map.computeIfAbsent(key, k -> new HashMap<>()).put(id, value); + } + + public T remove(String key, String id) { + Map innerMap = this.map.get(key); + if (innerMap != null) { + T removedElement = innerMap.remove(id); + if (innerMap.isEmpty()) { + this.map.remove(key); + } + return removedElement; + } + return null; + } + + public T get(String key1, String key2) { + Map innerMap = this.map.get(key1); + if (innerMap == null) { + return null; + } + return innerMap.get(key2); + } + + public Collection get(String key) { + Map innerMap = this.map.get(key); + if (innerMap == null) { + return Collections.emptyList(); + } + return new Vector<>(innerMap.values()); + } + + public Set keySet() { + return this.map.keySet(); + } + + public void clear() { + this.map.clear(); + } + +} diff --git a/src/main/java/org/oran/dmaapadapter/tasks/DmaapTopicConsumer.java b/src/main/java/org/oran/dmaapadapter/tasks/DmaapTopicConsumer.java new file mode 100644 index 0000000..fe7ec8b --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/tasks/DmaapTopicConsumer.java @@ -0,0 +1,107 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.tasks; + +import java.time.Duration; + +import org.oran.dmaapadapter.clients.AsyncRestClient; +import org.oran.dmaapadapter.clients.AsyncRestClientFactory; +import org.oran.dmaapadapter.configuration.ApplicationConfig; +import org.oran.dmaapadapter.repository.InfoType; +import org.oran.dmaapadapter.repository.Jobs; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * The class fetches incoming requests from DMAAP and sends them further to the + * consumers that has a job for this InformationType. + */ +public class DmaapTopicConsumer { + private static final Duration TIME_BETWEEN_DMAAP_RETRIES = Duration.ofSeconds(10); + private static final Logger logger = LoggerFactory.getLogger(DmaapTopicConsumer.class); + + private final AsyncRestClient dmaapRestClient; + protected final ApplicationConfig applicationConfig; + protected final InfoType type; + protected final Jobs jobs; + + public DmaapTopicConsumer(ApplicationConfig applicationConfig, InfoType type, Jobs jobs) { + AsyncRestClientFactory restclientFactory = new AsyncRestClientFactory(applicationConfig.getWebClientConfig()); + this.dmaapRestClient = restclientFactory.createRestClientNoHttpProxy(""); + this.applicationConfig = applicationConfig; + this.type = type; + this.jobs = jobs; + } + + public void start() { + Flux.range(0, Integer.MAX_VALUE) // + .flatMap(notUsed -> getFromMessageRouter(getDmaapUrl()), 1) // + .flatMap(this::pushDataToConsumers) // + .subscribe(// + null, // + throwable -> logger.error("DmaapMessageConsumer error: {}", throwable.getMessage()), // + this::onComplete); // + } + + private void onComplete() { + logger.warn("DmaapMessageConsumer completed {}", type.getId()); + start(); + } + + private String getDmaapUrl() { + return this.applicationConfig.getDmaapBaseUrl() + type.getDmaapTopicUrl(); + } + + private Mono handleDmaapErrorResponse(Throwable t) { + logger.debug("error from DMAAP {} {}", t.getMessage(), type.getDmaapTopicUrl()); + return Mono.delay(TIME_BETWEEN_DMAAP_RETRIES) // + .flatMap(notUsed -> Mono.empty()); + } + + private Mono getFromMessageRouter(String topicUrl) { + logger.trace("getFromMessageRouter {}", topicUrl); + return dmaapRestClient.get(topicUrl) // + .filter(body -> body.length() > 3) // DMAAP will return "[]" sometimes. That is thrown away. + .doOnNext(message -> logger.debug("Message from DMAAP topic: {} : {}", topicUrl, message)) // + .onErrorResume(this::handleDmaapErrorResponse); // + } + + private Mono handleConsumerErrorResponse(Throwable t) { + logger.warn("error from CONSUMER {}", t.getMessage()); + return Mono.empty(); + } + + protected Flux pushDataToConsumers(String body) { + logger.debug("Received data {}", body); + final int CONCURRENCY = 50; + + // Distibute the body to all jobs for this type + return Flux.fromIterable(this.jobs.getJobsForType(this.type)) // + .filter(job -> job.isFilterMatch(body)) // + .doOnNext(job -> logger.debug("Sending to consumer {}", job.getCallbackUrl())) // + .flatMap(job -> job.getConsumerRestClient().post("", body, MediaType.APPLICATION_JSON), CONCURRENCY) // + .onErrorResume(this::handleConsumerErrorResponse); + } +} diff --git a/src/main/java/org/oran/dmaapadapter/tasks/DmaapTopicConsumers.java b/src/main/java/org/oran/dmaapadapter/tasks/DmaapTopicConsumers.java new file mode 100644 index 0000000..9447c3a --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/tasks/DmaapTopicConsumers.java @@ -0,0 +1,43 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.tasks; + +import org.oran.dmaapadapter.configuration.ApplicationConfig; +import org.oran.dmaapadapter.repository.InfoType; +import org.oran.dmaapadapter.repository.InfoTypes; +import org.oran.dmaapadapter.repository.Jobs; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class DmaapTopicConsumers { + + DmaapTopicConsumers(@Autowired ApplicationConfig appConfig, @Autowired InfoTypes types, @Autowired Jobs jobs) { + // Start a consumer for each type + for (InfoType type : types.getAll()) { + if (type.isDmaapTopicDefined()) { + DmaapTopicConsumer topicConsumer = new DmaapTopicConsumer(appConfig, type, jobs); + topicConsumer.start(); + } + } + } + +} diff --git a/src/main/java/org/oran/dmaapadapter/tasks/KafkaJobDataConsumer.java b/src/main/java/org/oran/dmaapadapter/tasks/KafkaJobDataConsumer.java new file mode 100644 index 0000000..2a16f47 --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/tasks/KafkaJobDataConsumer.java @@ -0,0 +1,142 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.tasks; + +import lombok.Getter; + +import org.oran.dmaapadapter.repository.Job; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * The class streams data from a multi cast sink and sends the data to the Job + * owner via REST calls. + */ +@SuppressWarnings("squid:S2629") // Invoke method(s) only conditionally +public class KafkaJobDataConsumer { + private static final Logger logger = LoggerFactory.getLogger(KafkaJobDataConsumer.class); + @Getter + private final Job job; + private Disposable subscription; + private final ErrorStats errorStats = new ErrorStats(); + + private class ErrorStats { + private int consumerFaultCounter = 0; + private boolean kafkaError = false; // eg. overflow + + public void handleOkFromConsumer() { + this.consumerFaultCounter = 0; + } + + public void handleException(Throwable t) { + if (t instanceof WebClientResponseException) { + ++this.consumerFaultCounter; + } else { + kafkaError = true; + } + } + + public boolean isItHopeless() { + final int STOP_AFTER_ERRORS = 5; + return kafkaError || consumerFaultCounter > STOP_AFTER_ERRORS; + } + + public void resetKafkaErrors() { + kafkaError = false; + } + } + + public KafkaJobDataConsumer(Job job) { + this.job = job; + } + + public synchronized void start(Flux input) { + stop(); + this.errorStats.resetKafkaErrors(); + this.subscription = getMessagesFromKafka(input, job) // + .flatMap(this::postToClient, job.getParameters().getMaxConcurrency()) // + .onErrorResume(this::handleError) // + .subscribe(this::handleConsumerSentOk, // + this::handleExceptionInStream, // + () -> logger.warn("KafkaMessageConsumer stopped jobId: {}", job.getId())); + } + + private void handleExceptionInStream(Throwable t) { + logger.warn("KafkaMessageConsumer exception: {}, jobId: {}", t.getMessage(), job.getId()); + stop(); + } + + private Mono postToClient(String body) { + logger.debug("Sending to consumer {} {} {}", job.getId(), job.getCallbackUrl(), body); + MediaType contentType = this.job.isBuffered() ? MediaType.APPLICATION_JSON : null; + return job.getConsumerRestClient().post("", body, contentType); + } + + public synchronized void stop() { + if (this.subscription != null) { + this.subscription.dispose(); + this.subscription = null; + } + } + + public synchronized boolean isRunning() { + return this.subscription != null; + } + + private Flux getMessagesFromKafka(Flux input, Job job) { + Flux result = input.filter(job::isFilterMatch); + + if (job.isBuffered()) { + result = result.map(this::quote) // + .bufferTimeout( // + job.getParameters().getBufferTimeout().getMaxSize(), // + job.getParameters().getBufferTimeout().getMaxTime()) // + .map(Object::toString); + } + return result; + } + + private String quote(String str) { + final String q = "\""; + return q + str.replace(q, "\\\"") + q; + } + + private Mono handleError(Throwable t) { + logger.warn("exception: {} job: {}", t.getMessage(), job.getId()); + this.errorStats.handleException(t); + if (this.errorStats.isItHopeless()) { + return Mono.error(t); + } else { + return Mono.empty(); // Ignore + } + } + + private void handleConsumerSentOk(String data) { + this.errorStats.handleOkFromConsumer(); + } + +} diff --git a/src/main/java/org/oran/dmaapadapter/tasks/KafkaTopicConsumers.java b/src/main/java/org/oran/dmaapadapter/tasks/KafkaTopicConsumers.java new file mode 100644 index 0000000..5233401 --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/tasks/KafkaTopicConsumers.java @@ -0,0 +1,120 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.tasks; + +import java.util.HashMap; +import java.util.Map; + +import lombok.Getter; + +import org.oran.dmaapadapter.configuration.ApplicationConfig; +import org.oran.dmaapadapter.repository.InfoType; +import org.oran.dmaapadapter.repository.InfoTypes; +import org.oran.dmaapadapter.repository.Job; +import org.oran.dmaapadapter.repository.Jobs; +import org.oran.dmaapadapter.repository.MultiMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@SuppressWarnings("squid:S2629") // Invoke method(s) only conditionally +@Component +@EnableScheduling +public class KafkaTopicConsumers { + private static final Logger logger = LoggerFactory.getLogger(KafkaTopicConsumers.class); + + private final Map topicListeners = new HashMap<>(); // Key is typeId + + @Getter + private final MultiMap consumers = new MultiMap<>(); // Key is typeId, jobId + + private static final int CONSUMER_SUPERVISION_INTERVAL_MS = 1000 * 60 * 3; + + public KafkaTopicConsumers(@Autowired ApplicationConfig appConfig, @Autowired InfoTypes types, + @Autowired Jobs jobs) { + + for (InfoType type : types.getAll()) { + if (type.isKafkaTopicDefined()) { + KafkaTopicListener topicConsumer = new KafkaTopicListener(appConfig, type); + topicListeners.put(type.getId(), topicConsumer); + } + } + + jobs.addObserver(new Jobs.Observer() { + @Override + public void onJobbAdded(Job job) { + addJob(job); + } + + @Override + public void onJobRemoved(Job job) { + removeJob(job); + } + }); + } + + public synchronized void addJob(Job job) { + if (job.getType().isKafkaTopicDefined()) { + removeJob(job); + logger.debug("Kafka job added {}", job.getId()); + KafkaTopicListener topicConsumer = topicListeners.get(job.getType().getId()); + if (consumers.get(job.getType().getId()).isEmpty()) { + topicConsumer.start(); + } + KafkaJobDataConsumer subscription = new KafkaJobDataConsumer(job); + subscription.start(topicConsumer.getOutput().asFlux()); + consumers.put(job.getType().getId(), job.getId(), subscription); + } + } + + public synchronized void removeJob(Job job) { + KafkaJobDataConsumer d = consumers.remove(job.getType().getId(), job.getId()); + if (d != null) { + logger.debug("Kafka job removed {}", job.getId()); + d.stop(); + } + } + + @Scheduled(fixedRate = CONSUMER_SUPERVISION_INTERVAL_MS) + public synchronized void restartNonRunningTopics() { + for (String typeId : this.consumers.keySet()) { + for (KafkaJobDataConsumer consumer : this.consumers.get(typeId)) { + if (!consumer.isRunning()) { + restartTopic(consumer); + } + } + } + } + + private void restartTopic(KafkaJobDataConsumer consumer) { + InfoType type = consumer.getJob().getType(); + KafkaTopicListener topic = this.topicListeners.get(type.getId()); + topic.start(); + restartConsumersOfType(topic, type); + } + + private void restartConsumersOfType(KafkaTopicListener topic, InfoType type) { + this.consumers.get(type.getId()).forEach(consumer -> consumer.start(topic.getOutput().asFlux())); + } +} diff --git a/src/main/java/org/oran/dmaapadapter/tasks/KafkaTopicListener.java b/src/main/java/org/oran/dmaapadapter/tasks/KafkaTopicListener.java new file mode 100644 index 0000000..f7c1c9d --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/tasks/KafkaTopicListener.java @@ -0,0 +1,105 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.tasks; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.oran.dmaapadapter.configuration.ApplicationConfig; +import org.oran.dmaapadapter.repository.InfoType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import reactor.core.Disposable; +import reactor.core.publisher.Sinks; +import reactor.core.publisher.Sinks.Many; +import reactor.kafka.receiver.KafkaReceiver; +import reactor.kafka.receiver.ReceiverOptions; + +/** + * The class streams incoming requests from a Kafka topic and sends them further + * to a multi cast sink, which several other streams can connect to. + */ +@SuppressWarnings("squid:S2629") // Invoke method(s) only conditionally +public class KafkaTopicListener { + private static final Logger logger = LoggerFactory.getLogger(KafkaTopicListener.class); + private final ApplicationConfig applicationConfig; + private final InfoType type; + private Many output; + private Disposable topicReceiverTask; + + public KafkaTopicListener(ApplicationConfig applicationConfig, InfoType type) { + this.applicationConfig = applicationConfig; + this.type = type; + } + + public Many getOutput() { + return this.output; + } + + public void start() { + stop(); + final int CONSUMER_BACKPRESSURE_BUFFER_SIZE = 1024 * 10; + this.output = Sinks.many().multicast().onBackpressureBuffer(CONSUMER_BACKPRESSURE_BUFFER_SIZE); + logger.debug("Listening to kafka topic: {} type :{}", this.type.getKafkaInputTopic(), type.getId()); + topicReceiverTask = KafkaReceiver.create(kafkaInputProperties()) // + .receive() // + .doOnNext(this::onReceivedData) // + .subscribe(null, // + this::onReceivedError, // + () -> logger.warn("KafkaTopicReceiver stopped")); + } + + private void stop() { + if (topicReceiverTask != null) { + topicReceiverTask.dispose(); + topicReceiverTask = null; + } + } + + private void onReceivedData(ConsumerRecord input) { + logger.debug("Received from kafka topic: {} :{}", this.type.getKafkaInputTopic(), input.value()); + output.emitNext(input.value(), Sinks.EmitFailureHandler.FAIL_FAST); + } + + private void onReceivedError(Throwable t) { + logger.error("KafkaTopicReceiver error: {}", t.getMessage()); + } + + private ReceiverOptions kafkaInputProperties() { + Map consumerProps = new HashMap<>(); + if (this.applicationConfig.getKafkaBootStrapServers().isEmpty()) { + logger.error("No kafka boostrap server is setup"); + } + consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, this.applicationConfig.getKafkaBootStrapServers()); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "osc-dmaap-adapter"); + consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + + return ReceiverOptions.create(consumerProps) + .subscription(Collections.singleton(this.type.getKafkaInputTopic())); + } + +} diff --git a/src/main/java/org/oran/dmaapadapter/tasks/ProducerRegstrationTask.java b/src/main/java/org/oran/dmaapadapter/tasks/ProducerRegstrationTask.java new file mode 100644 index 0000000..ec3f2b2 --- /dev/null +++ b/src/main/java/org/oran/dmaapadapter/tasks/ProducerRegstrationTask.java @@ -0,0 +1,192 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter.tasks; + +import com.google.common.io.CharStreams; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +import lombok.Getter; + +import org.oran.dmaapadapter.clients.AsyncRestClient; +import org.oran.dmaapadapter.clients.AsyncRestClientFactory; +import org.oran.dmaapadapter.configuration.ApplicationConfig; +import org.oran.dmaapadapter.controllers.ProducerCallbacksController; +import org.oran.dmaapadapter.exceptions.ServiceException; +import org.oran.dmaapadapter.r1.ProducerInfoTypeInfo; +import org.oran.dmaapadapter.r1.ProducerRegistrationInfo; +import org.oran.dmaapadapter.repository.InfoType; +import org.oran.dmaapadapter.repository.InfoTypes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Registers the types and this producer in Innformation Coordinator Service. + * This is done when needed. + */ +@Component +@EnableScheduling +@SuppressWarnings("squid:S2629") // Invoke method(s) only conditionally +public class ProducerRegstrationTask { + + private static final Logger logger = LoggerFactory.getLogger(ProducerRegstrationTask.class); + private final AsyncRestClient restClient; + private final ApplicationConfig applicationConfig; + private final InfoTypes types; + private static com.google.gson.Gson gson = new com.google.gson.GsonBuilder().create(); + + private static final String PRODUCER_ID = "DmaapGenericInfoProducer"; + @Getter + private boolean isRegisteredInIcs = false; + private static final int REGISTRATION_SUPERVISION_INTERVAL_MS = 1000 * 5; + + public ProducerRegstrationTask(@Autowired ApplicationConfig applicationConfig, @Autowired InfoTypes types) { + AsyncRestClientFactory restClientFactory = new AsyncRestClientFactory(applicationConfig.getWebClientConfig()); + this.restClient = restClientFactory.createRestClientNoHttpProxy(""); + this.applicationConfig = applicationConfig; + this.types = types; + } + + @Scheduled(fixedRate = REGISTRATION_SUPERVISION_INTERVAL_MS) + public void runSupervisionTask() { + supervisionTask().subscribe( // + null, // + this::handleRegistrationFailure, // + this::handleRegistrationCompleted); + } + + public Mono supervisionTask() { + return checkRegistration() // + .filter(isRegistrationOk -> !isRegistrationOk || !this.isRegisteredInIcs) // + .flatMap(isRegisterred -> registerTypesAndProducer()); + } + + private void handleRegistrationCompleted() { + isRegisteredInIcs = true; + } + + private void handleRegistrationFailure(Throwable t) { + logger.warn("Registration of producer failed {}", t.getMessage()); + } + + // Returns TRUE if registration is correct + private Mono checkRegistration() { + final String url = applicationConfig.getIcsBaseUrl() + "/data-producer/v1/info-producers/" + PRODUCER_ID; + return restClient.get(url) // + .flatMap(this::isRegisterredInfoCorrect) // + .onErrorResume(t -> Mono.just(Boolean.FALSE)); + } + + private Mono isRegisterredInfoCorrect(String registerredInfoStr) { + ProducerRegistrationInfo registerredInfo = gson.fromJson(registerredInfoStr, ProducerRegistrationInfo.class); + if (isEqual(producerRegistrationInfo(), registerredInfo)) { + logger.trace("Already registered in ICS"); + return Mono.just(Boolean.TRUE); + } else { + return Mono.just(Boolean.FALSE); + } + } + + private String registerTypeUrl(InfoType type) { + return applicationConfig.getIcsBaseUrl() + "/data-producer/v1/info-types/" + type.getId(); + } + + private Mono registerTypesAndProducer() { + final int CONCURRENCY = 20; + final String producerUrl = + applicationConfig.getIcsBaseUrl() + "/data-producer/v1/info-producers/" + PRODUCER_ID; + + return Flux.fromIterable(this.types.getAll()) // + .doOnNext(type -> logger.info("Registering type {}", type.getId())) // + .flatMap(type -> restClient.put(registerTypeUrl(type), gson.toJson(typeRegistrationInfo(type))), + CONCURRENCY) // + .collectList() // + .doOnNext(type -> logger.info("Registering producer")) // + .flatMap(resp -> restClient.put(producerUrl, gson.toJson(producerRegistrationInfo()))); + } + + private Object typeSpecifcInfoObject() { + return jsonObject("{}"); + } + + private ProducerInfoTypeInfo typeRegistrationInfo(InfoType type) { + try { + return new ProducerInfoTypeInfo(jsonSchemaObject(type), typeSpecifcInfoObject()); + } catch (Exception e) { + logger.error("Fatal error {}", e.getMessage()); + return null; + } + } + + private Object jsonSchemaObject(InfoType type) throws IOException, ServiceException { + String schemaFile = type.isKafkaTopicDefined() ? "/typeSchemaKafka.json" : "/typeSchemaDmaap.json"; + return jsonObject(readSchemaFile(schemaFile)); + } + + private String readSchemaFile(String filePath) throws IOException, ServiceException { + InputStream in = getClass().getResourceAsStream(filePath); + logger.debug("Reading application schema file from: {} with: {}", filePath, in); + if (in == null) { + throw new ServiceException("Could not readfile: " + filePath, HttpStatus.INTERNAL_SERVER_ERROR); + } + return CharStreams.toString(new InputStreamReader(in, StandardCharsets.UTF_8)); + } + + @SuppressWarnings("java:S2139") // Log exception + private Object jsonObject(String json) { + try { + return JsonParser.parseString(json).getAsJsonObject(); + } catch (Exception e) { + logger.error("Bug, error in JSON: {} {}", json, e.getMessage()); + throw new NullPointerException(e.getMessage()); + } + } + + private boolean isEqual(ProducerRegistrationInfo a, ProducerRegistrationInfo b) { + return a.jobCallbackUrl.equals(b.jobCallbackUrl) // + && a.producerSupervisionCallbackUrl.equals(b.producerSupervisionCallbackUrl) // + && a.supportedTypeIds.size() == b.supportedTypeIds.size(); + } + + private ProducerRegistrationInfo producerRegistrationInfo() { + return ProducerRegistrationInfo.builder() // + .jobCallbackUrl(baseUrl() + ProducerCallbacksController.JOB_URL) // + .producerSupervisionCallbackUrl(baseUrl() + ProducerCallbacksController.SUPERVISION_URL) // + .supportedTypeIds(this.types.typeIds()) // + .build(); + } + + private String baseUrl() { + return this.applicationConfig.getSelfUrl(); + } +} diff --git a/src/main/resources/typeSchemaDmaap.json b/src/main/resources/typeSchemaDmaap.json new file mode 100644 index 0000000..a50b236 --- /dev/null +++ b/src/main/resources/typeSchemaDmaap.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "filter": { + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/src/main/resources/typeSchemaKafka.json b/src/main/resources/typeSchemaKafka.json new file mode 100644 index 0000000..f7e6e87 --- /dev/null +++ b/src/main/resources/typeSchemaKafka.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "filter": { + "type": "string" + }, + "maxConcurrency": { + "type": "integer", + "minimum": 1 + }, + "bufferTimeout": { + "type": "object", + "properties": { + "maxSize": { + "type": "integer", + "minimum": 1 + }, + "maxTimeMiliseconds": { + "type": "integer", + "minimum": 0, + "maximum": 160000 + } + }, + "additionalProperties": false, + "required": [ + "maxSize", + "maxTimeMiliseconds" + ] + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/src/test/java/org/oran/dmaapadapter/ApplicationTest.java b/src/test/java/org/oran/dmaapadapter/ApplicationTest.java new file mode 100644 index 0000000..6660175 --- /dev/null +++ b/src/test/java/org/oran/dmaapadapter/ApplicationTest.java @@ -0,0 +1,360 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonParser; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Paths; + +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.oran.dmaapadapter.clients.AsyncRestClient; +import org.oran.dmaapadapter.clients.AsyncRestClientFactory; +import org.oran.dmaapadapter.configuration.ApplicationConfig; +import org.oran.dmaapadapter.configuration.ImmutableHttpProxyConfig; +import org.oran.dmaapadapter.configuration.ImmutableWebClientConfig; +import org.oran.dmaapadapter.configuration.WebClientConfig; +import org.oran.dmaapadapter.configuration.WebClientConfig.HttpProxyConfig; +import org.oran.dmaapadapter.controllers.ProducerCallbacksController; +import org.oran.dmaapadapter.r1.ConsumerJobInfo; +import org.oran.dmaapadapter.r1.ProducerJobInfo; +import org.oran.dmaapadapter.repository.InfoTypes; +import org.oran.dmaapadapter.repository.Job; +import org.oran.dmaapadapter.repository.Jobs; +import org.oran.dmaapadapter.tasks.KafkaJobDataConsumer; +import org.oran.dmaapadapter.tasks.KafkaTopicConsumers; +import org.oran.dmaapadapter.tasks.ProducerRegstrationTask; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.boot.web.servlet.server.ServletWebServerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = { // + "server.ssl.key-store=./config/keystore.jks", // + "app.webclient.trust-store=./config/truststore.jks", // + "app.configuration-filepath=./src/test/resources/test_application_configuration.json"// +}) +class ApplicationTest { + + @Autowired + private ApplicationConfig applicationConfig; + + @Autowired + private Jobs jobs; + + @Autowired + private InfoTypes types; + + @Autowired + private ConsumerController consumerController; + + @Autowired + private IcsSimulatorController icsSimulatorController; + + @Autowired + KafkaTopicConsumers kafkaTopicConsumers; + + @Autowired + ProducerRegstrationTask producerRegistrationTask; + + private com.google.gson.Gson gson = new com.google.gson.GsonBuilder().create(); + + @LocalServerPort + int localServerHttpPort; + + static class TestApplicationConfig extends ApplicationConfig { + @Override + public String getIcsBaseUrl() { + return thisProcessUrl(); + } + + @Override + public String getDmaapBaseUrl() { + return thisProcessUrl(); + } + + @Override + public String getSelfUrl() { + return thisProcessUrl(); + } + + private String thisProcessUrl() { + final String url = "https://localhost:" + getLocalServerHttpPort(); + return url; + } + } + + /** + * Overrides the BeanFactory. + */ + @TestConfiguration + static class TestBeanFactory extends BeanFactory { + + @Override + @Bean + public ServletWebServerFactory servletContainer() { + return new TomcatServletWebServerFactory(); + } + + @Override + @Bean + public ApplicationConfig getApplicationConfig() { + TestApplicationConfig cfg = new TestApplicationConfig(); + return cfg; + } + } + + @BeforeEach + void setPort() { + this.applicationConfig.setLocalServerHttpPort(this.localServerHttpPort); + } + + @AfterEach + void reset() { + this.consumerController.testResults.reset(); + this.icsSimulatorController.testResults.reset(); + this.jobs.clear(); + } + + private AsyncRestClient restClient(boolean useTrustValidation) { + WebClientConfig config = this.applicationConfig.getWebClientConfig(); + HttpProxyConfig httpProxyConfig = ImmutableHttpProxyConfig.builder() // + .httpProxyHost("") // + .httpProxyPort(0) // + .build(); + config = ImmutableWebClientConfig.builder() // + .keyStoreType(config.keyStoreType()) // + .keyStorePassword(config.keyStorePassword()) // + .keyStore(config.keyStore()) // + .keyPassword(config.keyPassword()) // + .isTrustStoreUsed(useTrustValidation) // + .trustStore(config.trustStore()) // + .trustStorePassword(config.trustStorePassword()) // + .httpProxyConfig(httpProxyConfig).build(); + + AsyncRestClientFactory restClientFactory = new AsyncRestClientFactory(config); + return restClientFactory.createRestClientNoHttpProxy(baseUrl()); + } + + private AsyncRestClient restClient() { + return restClient(false); + } + + private String baseUrl() { + return "https://localhost:" + this.applicationConfig.getLocalServerHttpPort(); + } + + private ConsumerJobInfo consumerJobInfo() { + return consumerJobInfo("DmaapInformationType", "EI_JOB_ID"); + } + + private Object jsonObject() { + return jsonObject("{}"); + } + + private Object jsonObject(String json) { + try { + return JsonParser.parseString(json).getAsJsonObject(); + } catch (Exception e) { + throw new NullPointerException(e.toString()); + } + } + + private ConsumerJobInfo consumerJobInfo(String typeId, String infoJobId) { + try { + String targetUri = baseUrl() + ConsumerController.CONSUMER_TARGET_URL; + return new ConsumerJobInfo(typeId, jsonObject(), "owner", targetUri, ""); + } catch (Exception e) { + return null; + } + } + + @Test + void generateApiDoc() throws IOException { + String url = "https://localhost:" + applicationConfig.getLocalServerHttpPort() + "/v3/api-docs"; + ResponseEntity resp = restClient().getForEntity(url).block(); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + JSONObject jsonObj = new JSONObject(resp.getBody()); + assertThat(jsonObj.remove("servers")).isNotNull(); + + String indented = (jsonObj).toString(4); + String docDir = "api/"; + Files.createDirectories(Paths.get(docDir)); + try (PrintStream out = new PrintStream(new FileOutputStream(docDir + "api.json"))) { + out.print(indented); + } + } + + @Test + void testResponseCodes() throws Exception { + String supervisionUrl = baseUrl() + ProducerCallbacksController.SUPERVISION_URL; + ResponseEntity resp = restClient().getForEntity(supervisionUrl).block(); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + + String jobUrl = baseUrl() + ProducerCallbacksController.JOB_URL; + resp = restClient().deleteForEntity(jobUrl + "/junk").block(); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + + ProducerJobInfo info = new ProducerJobInfo(null, "id", "typeId", "targetUri", "owner", "lastUpdated"); + String body = gson.toJson(info); + testErrorCode(restClient().post(jobUrl, body, MediaType.APPLICATION_JSON), HttpStatus.NOT_FOUND, + "Could not find type"); + } + + @Test + void testReceiveAndPostDataFromKafka() { + final String JOB_ID = "ID"; + final String TYPE_ID = "KafkaInformationType"; + await().untilAsserted(() -> assertThat(icsSimulatorController.testResults.registrationInfo).isNotNull()); + assertThat(icsSimulatorController.testResults.registrationInfo.supportedTypeIds).hasSize(this.types.size()); + + // Create a job + Job.Parameters param = new Job.Parameters("", new Job.BufferTimeout(123, 456), 1); + String targetUri = baseUrl() + ConsumerController.CONSUMER_TARGET_URL; + ConsumerJobInfo kafkaJobInfo = + new ConsumerJobInfo(TYPE_ID, jsonObject(gson.toJson(param)), "owner", targetUri, ""); + + this.icsSimulatorController.addJob(kafkaJobInfo, JOB_ID, restClient()); + await().untilAsserted(() -> assertThat(this.jobs.size()).isEqualTo(1)); + + KafkaJobDataConsumer kafkaConsumer = this.kafkaTopicConsumers.getConsumers().get(TYPE_ID, JOB_ID); + + // Handle received data from Kafka, check that it has been posted to the + // consumer + kafkaConsumer.start(Flux.just("data")); + + ConsumerController.TestResults consumer = this.consumerController.testResults; + await().untilAsserted(() -> assertThat(consumer.receivedBodies.size()).isEqualTo(1)); + assertThat(consumer.receivedBodies.get(0)).isEqualTo("[\"data\"]"); + + // Test send an exception + kafkaConsumer.start(Flux.error(new NullPointerException())); + + // Test regular restart of stopped + kafkaConsumer.stop(); + this.kafkaTopicConsumers.restartNonRunningTopics(); + await().untilAsserted(() -> assertThat(kafkaConsumer.isRunning()).isTrue()); + + // Delete the job + this.icsSimulatorController.deleteJob(JOB_ID, restClient()); + await().untilAsserted(() -> assertThat(this.jobs.size()).isZero()); + } + + @Test + void testReceiveAndPostDataFromDmaap() throws Exception { + final String JOB_ID = "ID"; + + // Register producer, Register types + await().untilAsserted(() -> assertThat(icsSimulatorController.testResults.registrationInfo).isNotNull()); + assertThat(icsSimulatorController.testResults.registrationInfo.supportedTypeIds).hasSize(this.types.size()); + assertThat(producerRegistrationTask.isRegisteredInIcs()).isTrue(); + producerRegistrationTask.supervisionTask().block(); + + // Create a job + this.icsSimulatorController.addJob(consumerJobInfo(), JOB_ID, restClient()); + await().untilAsserted(() -> assertThat(this.jobs.size()).isEqualTo(1)); + + // Return two messages from DMAAP and verify that these are sent to the owner of + // the job (consumer) + DmaapSimulatorController.dmaapResponses.add("DmaapResponse1"); + DmaapSimulatorController.dmaapResponses.add("DmaapResponse2"); + ConsumerController.TestResults consumer = this.consumerController.testResults; + await().untilAsserted(() -> assertThat(consumer.receivedBodies.size()).isEqualTo(2)); + assertThat(consumer.receivedBodies.get(0)).isEqualTo("DmaapResponse1"); + + String jobUrl = baseUrl() + ProducerCallbacksController.JOB_URL; + String jobs = restClient().get(jobUrl).block(); + assertThat(jobs).contains(JOB_ID); + + // Delete the job + this.icsSimulatorController.deleteJob(JOB_ID, restClient()); + await().untilAsserted(() -> assertThat(this.jobs.size()).isZero()); + } + + @Test + void testReRegister() throws Exception { + // Wait foir register types and producer + await().untilAsserted(() -> assertThat(icsSimulatorController.testResults.registrationInfo).isNotNull()); + assertThat(icsSimulatorController.testResults.registrationInfo.supportedTypeIds).hasSize(this.types.size()); + + // Clear the registration, should trigger a re-register + icsSimulatorController.testResults.reset(); + await().untilAsserted(() -> assertThat(icsSimulatorController.testResults.registrationInfo).isNotNull()); + assertThat(icsSimulatorController.testResults.registrationInfo.supportedTypeIds).hasSize(this.types.size()); + + // Just clear the registerred types, should trigger a re-register + icsSimulatorController.testResults.types.clear(); + await().untilAsserted( + () -> assertThat(icsSimulatorController.testResults.registrationInfo.supportedTypeIds).hasSize(2)); + } + + public static void testErrorCode(Mono request, HttpStatus expStatus, String responseContains) { + testErrorCode(request, expStatus, responseContains, true); + } + + public static void testErrorCode(Mono request, HttpStatus expStatus, String responseContains, + boolean expectApplicationProblemJsonMediaType) { + StepVerifier.create(request) // + .expectSubscription() // + .expectErrorMatches( + t -> checkWebClientError(t, expStatus, responseContains, expectApplicationProblemJsonMediaType)) // + .verify(); + } + + private static boolean checkWebClientError(Throwable throwable, HttpStatus expStatus, String responseContains, + boolean expectApplicationProblemJsonMediaType) { + assertTrue(throwable instanceof WebClientResponseException); + WebClientResponseException responseException = (WebClientResponseException) throwable; + assertThat(responseException.getStatusCode()).isEqualTo(expStatus); + assertThat(responseException.getResponseBodyAsString()).contains(responseContains); + if (expectApplicationProblemJsonMediaType) { + assertThat(responseException.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PROBLEM_JSON); + } + return true; + } +} diff --git a/src/test/java/org/oran/dmaapadapter/ConsumerController.java b/src/test/java/org/oran/dmaapadapter/ConsumerController.java new file mode 100644 index 0000000..70e89d6 --- /dev/null +++ b/src/test/java/org/oran/dmaapadapter/ConsumerController.java @@ -0,0 +1,87 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.lang.invoke.MethodHandles; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.oran.dmaapadapter.controllers.VoidResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController("ConsumerSimulatorController") +@Tag(name = "Test Consumer Simulator (exists only in test)") +public class ConsumerController { + + private final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + public static final String CONSUMER_TARGET_URL = "/consumer"; + + public static class TestResults { + + public List receivedBodies = Collections.synchronizedList(new ArrayList()); + + public TestResults() {} + + public boolean hasReceived(String str) { + for (String received : receivedBodies) { + if (received.equals(str)) { + return true; + } + } + return false; + } + + public void reset() { + receivedBodies.clear(); + } + } + + final TestResults testResults = new TestResults(); + + @PostMapping(path = CONSUMER_TARGET_URL, produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "Consume data", description = "The call is invoked to push data to consumer") + @ApiResponses(value = { // + @ApiResponse(responseCode = "200", description = "OK", // + content = @Content(schema = @Schema(implementation = VoidResponse.class))) // + }) + public ResponseEntity postData(@RequestBody String body) { + logger.info("Received by consumer: {}", body); + testResults.receivedBodies.add(body); + return new ResponseEntity<>(HttpStatus.OK); + } + +} diff --git a/src/test/java/org/oran/dmaapadapter/DmaapSimulatorController.java b/src/test/java/org/oran/dmaapadapter/DmaapSimulatorController.java new file mode 100644 index 0000000..5259ee1 --- /dev/null +++ b/src/test/java/org/oran/dmaapadapter/DmaapSimulatorController.java @@ -0,0 +1,73 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.lang.invoke.MethodHandles; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +import org.oran.dmaapadapter.controllers.ErrorResponse; +import org.oran.dmaapadapter.controllers.VoidResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController("DmaapSimulatorController") +@Tag(name = "DMAAP Simulator (exists only in test)") +public class DmaapSimulatorController { + + private final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + public static final String DMAAP_TOPIC_URL = "/dmaap-topic-1"; + + public static List dmaapResponses = Collections.synchronizedList(new LinkedList()); + + @GetMapping(path = DMAAP_TOPIC_URL, produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(summary = "GET from topic", + description = "The call is invoked to activate or to modify a data subscription. The endpoint is provided by the Information Producer.") + @ApiResponses(value = { // + @ApiResponse(responseCode = "200", description = "OK", // + content = @Content(schema = @Schema(implementation = VoidResponse.class))) // + }) + public ResponseEntity getFromTopic() { + if (dmaapResponses.isEmpty()) { + return ErrorResponse.create("", HttpStatus.NOT_FOUND); + } else { + String resp = dmaapResponses.remove(0); + logger.info("DMAAP simulator returned: {}", resp); + return new ResponseEntity<>(resp, HttpStatus.OK); + } + + } + +} diff --git a/src/test/java/org/oran/dmaapadapter/IcsSimulatorController.java b/src/test/java/org/oran/dmaapadapter/IcsSimulatorController.java new file mode 100644 index 0000000..790aafb --- /dev/null +++ b/src/test/java/org/oran/dmaapadapter/IcsSimulatorController.java @@ -0,0 +1,117 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.lang.invoke.MethodHandles; +import java.util.HashMap; +import java.util.Map; + +import org.oran.dmaapadapter.clients.AsyncRestClient; +import org.oran.dmaapadapter.r1.ConsumerJobInfo; +import org.oran.dmaapadapter.r1.ProducerInfoTypeInfo; +import org.oran.dmaapadapter.r1.ProducerJobInfo; +import org.oran.dmaapadapter.r1.ProducerRegistrationInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController("IcsSimulatorController") +@Tag(name = "Information Coordinator Service Simulator (exists only in test)") +public class IcsSimulatorController { + + private final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + private final static Gson gson = new GsonBuilder().create(); + + public static class TestResults { + + ProducerRegistrationInfo registrationInfo = null; + Map types = new HashMap<>(); + String infoProducerId = null; + + public TestResults() {} + + public void reset() { + registrationInfo = null; + types.clear(); + infoProducerId = null; + } + } + + final TestResults testResults = new TestResults(); + public static final String API_ROOT = "/data-producer/v1"; + + @GetMapping(path = API_ROOT + "/info-producers/{infoProducerId}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getInfoProducer( // + @PathVariable("infoProducerId") String infoProducerId) { + + if (testResults.registrationInfo != null) { + return new ResponseEntity<>(gson.toJson(testResults.registrationInfo), HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @PutMapping(path = API_ROOT + "/info-producers/{infoProducerId}", // + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity putInfoProducer( // + @PathVariable("infoProducerId") String infoProducerId, // + @RequestBody ProducerRegistrationInfo registrationInfo) { + testResults.registrationInfo = registrationInfo; + testResults.infoProducerId = infoProducerId; + return new ResponseEntity<>(HttpStatus.OK); + } + + @PutMapping(path = API_ROOT + "/info-types/{infoTypeId}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity putInfoType( // + @PathVariable("infoTypeId") String infoTypeId, // + @RequestBody ProducerInfoTypeInfo registrationInfo) { + testResults.types.put(infoTypeId, registrationInfo); + return new ResponseEntity<>(HttpStatus.OK); + } + + public void addJob(ConsumerJobInfo job, String jobId, AsyncRestClient restClient) { + String url = this.testResults.registrationInfo.jobCallbackUrl; + ProducerJobInfo request = + new ProducerJobInfo(job.jobDefinition, jobId, job.infoTypeId, job.jobResultUri, job.owner, "TIMESTAMP"); + String body = gson.toJson(request); + logger.info("ICS Simulator PUT job: {}", body); + restClient.post(url, body, MediaType.APPLICATION_JSON).block(); + } + + public void deleteJob(String jobId, AsyncRestClient restClient) { + String url = this.testResults.registrationInfo.jobCallbackUrl + "/" + jobId; + logger.info("ICS Simulator DELETE job: {}", url); + restClient.delete(url).block(); + + } +} diff --git a/src/test/java/org/oran/dmaapadapter/IntegrationWithIcs.java b/src/test/java/org/oran/dmaapadapter/IntegrationWithIcs.java new file mode 100644 index 0000000..9f0ef19 --- /dev/null +++ b/src/test/java/org/oran/dmaapadapter/IntegrationWithIcs.java @@ -0,0 +1,273 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParser; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.oran.dmaapadapter.clients.AsyncRestClient; +import org.oran.dmaapadapter.clients.AsyncRestClientFactory; +import org.oran.dmaapadapter.configuration.ApplicationConfig; +import org.oran.dmaapadapter.configuration.ImmutableHttpProxyConfig; +import org.oran.dmaapadapter.configuration.ImmutableWebClientConfig; +import org.oran.dmaapadapter.configuration.WebClientConfig; +import org.oran.dmaapadapter.configuration.WebClientConfig.HttpProxyConfig; +import org.oran.dmaapadapter.r1.ConsumerJobInfo; +import org.oran.dmaapadapter.repository.InfoTypes; +import org.oran.dmaapadapter.repository.Job; +import org.oran.dmaapadapter.repository.Jobs; +import org.oran.dmaapadapter.tasks.ProducerRegstrationTask; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.servlet.server.ServletWebServerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + + +@SuppressWarnings("java:S3577") // Rename class +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT) +@TestPropertySource(properties = { // + "server.ssl.key-store=./config/keystore.jks", // + "app.webclient.trust-store=./config/truststore.jks", // + "app.configuration-filepath=./src/test/resources/test_application_configuration.json", // + "app.ics-base-url=https://localhost:8434" // +}) +class IntegrationWithIcs { + + private static final String DMAAP_JOB_ID = "DMAAP_JOB_ID"; + private static final String DMAAP_TYPE_ID = "DmaapInformationType"; + + @Autowired + private ApplicationConfig applicationConfig; + + @Autowired + private ProducerRegstrationTask producerRegstrationTask; + + @Autowired + private Jobs jobs; + + @Autowired + private InfoTypes types; + + @Autowired + private ConsumerController consumerController; + + private static Gson gson = new GsonBuilder().create(); + + static class TestApplicationConfig extends ApplicationConfig { + + @Override + public String getIcsBaseUrl() { + return "https://localhost:8434"; + } + + @Override + public String getDmaapBaseUrl() { + return thisProcessUrl(); + } + + @Override + public String getSelfUrl() { + return thisProcessUrl(); + } + + private String thisProcessUrl() { + final String url = "https://localhost:" + getLocalServerHttpPort(); + return url; + } + } + + /** + * Overrides the BeanFactory. + */ + @TestConfiguration + static class TestBeanFactory extends BeanFactory { + + @Override + @Bean + public ServletWebServerFactory servletContainer() { + return new TomcatServletWebServerFactory(); + } + + @Override + @Bean + public ApplicationConfig getApplicationConfig() { + TestApplicationConfig cfg = new TestApplicationConfig(); + return cfg; + } + } + + @AfterEach + void reset() { + this.consumerController.testResults.reset(); + assertThat(this.jobs.size()).isZero(); + } + + private AsyncRestClient restClient(boolean useTrustValidation) { + WebClientConfig config = this.applicationConfig.getWebClientConfig(); + HttpProxyConfig httpProxyConfig = ImmutableHttpProxyConfig.builder() // + .httpProxyHost("") // + .httpProxyPort(0) // + .build(); + config = ImmutableWebClientConfig.builder() // + .keyStoreType(config.keyStoreType()) // + .keyStorePassword(config.keyStorePassword()) // + .keyStore(config.keyStore()) // + .keyPassword(config.keyPassword()) // + .isTrustStoreUsed(useTrustValidation) // + .trustStore(config.trustStore()) // + .trustStorePassword(config.trustStorePassword()) // + .httpProxyConfig(httpProxyConfig).build(); + + AsyncRestClientFactory restClientFactory = new AsyncRestClientFactory(config); + return restClientFactory.createRestClientNoHttpProxy(selfBaseUrl()); + } + + private AsyncRestClient restClient() { + return restClient(false); + } + + private String selfBaseUrl() { + return "https://localhost:" + this.applicationConfig.getLocalServerHttpPort(); + } + + private String icsBaseUrl() { + return applicationConfig.getIcsBaseUrl(); + } + + private String jobUrl(String jobId) { + return icsBaseUrl() + "/data-consumer/v1/info-jobs/" + jobId + "?typeCheck=true"; + } + + private void createInformationJobInIcs(String typeId, String jobId, String filter) { + String body = gson.toJson(consumerJobInfo(typeId, filter)); + try { + // Delete the job if it already exists + deleteInformationJobInIcs(jobId); + } catch (Exception e) { + } + restClient().putForEntity(jobUrl(jobId), body).block(); + } + + private void deleteInformationJobInIcs(String jobId) { + restClient().delete(jobUrl(jobId)).block(); + } + + private ConsumerJobInfo consumerJobInfo(String typeId, String filter) { + return consumerJobInfo(typeId, DMAAP_JOB_ID, filter); + } + + private Object jsonObject(String json) { + try { + return JsonParser.parseString(json).getAsJsonObject(); + } catch (Exception e) { + throw new NullPointerException(e.toString()); + } + } + + private String quote(String str) { + return "\"" + str + "\""; + } + + private String consumerUri() { + return selfBaseUrl() + ConsumerController.CONSUMER_TARGET_URL; + } + + private ConsumerJobInfo consumerJobInfo(String typeId, String infoJobId, String filter) { + try { + + String jsonStr = "{ \"filter\" :" + quote(filter) + "}"; + return new ConsumerJobInfo(typeId, jsonObject(jsonStr), "owner", consumerUri(), ""); + } catch (Exception e) { + return null; + } + } + + @Test + void testCreateKafkaJob() { + await().untilAsserted(() -> assertThat(producerRegstrationTask.isRegisteredInIcs()).isTrue()); + final String TYPE_ID = "KafkaInformationType"; + + Job.Parameters param = new Job.Parameters("filter", new Job.BufferTimeout(123, 456), 1); + + ConsumerJobInfo jobInfo = + new ConsumerJobInfo(TYPE_ID, jsonObject(gson.toJson(param)), "owner", consumerUri(), ""); + String body = gson.toJson(jobInfo); + + restClient().putForEntity(jobUrl("KAFKA_JOB_ID"), body).block(); + + await().untilAsserted(() -> assertThat(this.jobs.size()).isEqualTo(1)); + + deleteInformationJobInIcs("KAFKA_JOB_ID"); + await().untilAsserted(() -> assertThat(this.jobs.size()).isZero()); + } + + @Test + void testKafkaJobParameterOutOfRange() { + await().untilAsserted(() -> assertThat(producerRegstrationTask.isRegisteredInIcs()).isTrue()); + final String TYPE_ID = "KafkaInformationType"; + + Job.Parameters param = new Job.Parameters("filter", new Job.BufferTimeout(123, 170 * 1000), 1); + + ConsumerJobInfo jobInfo = + new ConsumerJobInfo(TYPE_ID, jsonObject(gson.toJson(param)), "owner", consumerUri(), ""); + String body = gson.toJson(jobInfo); + + ApplicationTest.testErrorCode(restClient().put(jobUrl("KAFKA_JOB_ID"), body), HttpStatus.BAD_REQUEST, + "Json validation failure"); + + } + + @Test + void testDmaapMessage() throws Exception { + await().untilAsserted(() -> assertThat(producerRegstrationTask.isRegisteredInIcs()).isTrue()); + + createInformationJobInIcs(DMAAP_TYPE_ID, DMAAP_JOB_ID, ".*DmaapResponse.*"); + + await().untilAsserted(() -> assertThat(this.jobs.size()).isEqualTo(1)); + + DmaapSimulatorController.dmaapResponses.add("DmaapResponse1"); + DmaapSimulatorController.dmaapResponses.add("DmaapResponse2"); + DmaapSimulatorController.dmaapResponses.add("Junk"); + + ConsumerController.TestResults results = this.consumerController.testResults; + await().untilAsserted(() -> assertThat(results.receivedBodies.size()).isEqualTo(2)); + assertThat(results.receivedBodies.get(0)).isEqualTo("DmaapResponse1"); + + deleteInformationJobInIcs(DMAAP_JOB_ID); + + await().untilAsserted(() -> assertThat(this.jobs.size()).isZero()); + } + +} diff --git a/src/test/java/org/oran/dmaapadapter/IntegrationWithKafka.java b/src/test/java/org/oran/dmaapadapter/IntegrationWithKafka.java new file mode 100644 index 0000000..5a48d61 --- /dev/null +++ b/src/test/java/org/oran/dmaapadapter/IntegrationWithKafka.java @@ -0,0 +1,344 @@ +/*- + * ========================LICENSE_START================================= + * O-RAN-SC + * %% + * Copyright (C) 2021 Nordix Foundation + * %% + * 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 + * + * http://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. + * ========================LICENSE_END=================================== + */ + +package org.oran.dmaapadapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonParser; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.serialization.IntegerSerializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.oran.dmaapadapter.clients.AsyncRestClient; +import org.oran.dmaapadapter.clients.AsyncRestClientFactory; +import org.oran.dmaapadapter.configuration.ApplicationConfig; +import org.oran.dmaapadapter.configuration.ImmutableHttpProxyConfig; +import org.oran.dmaapadapter.configuration.ImmutableWebClientConfig; +import org.oran.dmaapadapter.configuration.WebClientConfig; +import org.oran.dmaapadapter.configuration.WebClientConfig.HttpProxyConfig; +import org.oran.dmaapadapter.r1.ConsumerJobInfo; +import org.oran.dmaapadapter.repository.InfoType; +import org.oran.dmaapadapter.repository.InfoTypes; +import org.oran.dmaapadapter.repository.Job; +import org.oran.dmaapadapter.repository.Jobs; +import org.oran.dmaapadapter.tasks.KafkaJobDataConsumer; +import org.oran.dmaapadapter.tasks.KafkaTopicConsumers; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.boot.web.servlet.server.ServletWebServerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import reactor.core.publisher.Flux; +import reactor.kafka.sender.KafkaSender; +import reactor.kafka.sender.SenderOptions; +import reactor.kafka.sender.SenderRecord; + +@SuppressWarnings("java:S3577") // Rename class +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT) +@TestPropertySource(properties = { // + "server.ssl.key-store=./config/keystore.jks", // + "app.webclient.trust-store=./config/truststore.jks", // + "app.configuration-filepath=./src/test/resources/test_application_configuration.json"// +}) +class IntegrationWithKafka { + + final String TYPE_ID = "KafkaInformationType"; + + @Autowired + private ApplicationConfig applicationConfig; + + @Autowired + private Jobs jobs; + + @Autowired + private InfoTypes types; + + @Autowired + private ConsumerController consumerController; + + @Autowired + private IcsSimulatorController icsSimulatorController; + + @Autowired + private KafkaTopicConsumers kafkaTopicConsumers; + + private static com.google.gson.Gson gson = new com.google.gson.GsonBuilder().create(); + + private static final Logger logger = LoggerFactory.getLogger(IntegrationWithKafka.class); + + @LocalServerPort + int localServerHttpPort; + + static class TestApplicationConfig extends ApplicationConfig { + @Override + public String getIcsBaseUrl() { + return thisProcessUrl(); + } + + @Override + public String getDmaapBaseUrl() { + return thisProcessUrl(); + } + + @Override + public String getSelfUrl() { + return thisProcessUrl(); + } + + private String thisProcessUrl() { + final String url = "https://localhost:" + getLocalServerHttpPort(); + return url; + } + } + + /** + * Overrides the BeanFactory. + */ + @TestConfiguration + static class TestBeanFactory extends BeanFactory { + + @Override + @Bean + public ServletWebServerFactory servletContainer() { + return new TomcatServletWebServerFactory(); + } + + @Override + @Bean + public ApplicationConfig getApplicationConfig() { + TestApplicationConfig cfg = new TestApplicationConfig(); + return cfg; + } + } + + @AfterEach + void reset() { + this.consumerController.testResults.reset(); + this.icsSimulatorController.testResults.reset(); + this.jobs.clear(); + } + + private AsyncRestClient restClient(boolean useTrustValidation) { + WebClientConfig config = this.applicationConfig.getWebClientConfig(); + HttpProxyConfig httpProxyConfig = ImmutableHttpProxyConfig.builder() // + .httpProxyHost("") // + .httpProxyPort(0) // + .build(); + config = ImmutableWebClientConfig.builder() // + .keyStoreType(config.keyStoreType()) // + .keyStorePassword(config.keyStorePassword()) // + .keyStore(config.keyStore()) // + .keyPassword(config.keyPassword()) // + .isTrustStoreUsed(useTrustValidation) // + .trustStore(config.trustStore()) // + .trustStorePassword(config.trustStorePassword()) // + .httpProxyConfig(httpProxyConfig).build(); + + AsyncRestClientFactory restClientFactory = new AsyncRestClientFactory(config); + return restClientFactory.createRestClientNoHttpProxy(baseUrl()); + } + + private AsyncRestClient restClient() { + return restClient(false); + } + + private String baseUrl() { + return "https://localhost:" + this.applicationConfig.getLocalServerHttpPort(); + } + + private static Object jobParametersAsJsonObject(String filter, long maxTimeMiliseconds, int maxSize, + int maxConcurrency) { + Job.Parameters param = + new Job.Parameters(filter, new Job.BufferTimeout(maxSize, maxTimeMiliseconds), maxConcurrency); + String str = gson.toJson(param); + return jsonObject(str); + } + + private static Object jsonObject(String json) { + try { + return JsonParser.parseString(json).getAsJsonObject(); + } catch (Exception e) { + throw new NullPointerException(e.toString()); + } + } + + ConsumerJobInfo consumerJobInfo(String filter, Duration maxTime, int maxSize, int maxConcurrency) { + try { + String targetUri = baseUrl() + ConsumerController.CONSUMER_TARGET_URL; + return new ConsumerJobInfo(TYPE_ID, + jobParametersAsJsonObject(filter, maxTime.toMillis(), maxSize, maxConcurrency), "owner", targetUri, + ""); + } catch (Exception e) { + return null; + } + } + + private SenderOptions senderOptions() { + String bootstrapServers = this.applicationConfig.getKafkaBootStrapServers(); + + Map props = new HashMap<>(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ProducerConfig.CLIENT_ID_CONFIG, "sample-producerx"); + props.put(ProducerConfig.ACKS_CONFIG, "all"); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, IntegerSerializer.class); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + return SenderOptions.create(props); + } + + private SenderRecord senderRecord(String data) { + final InfoType infoType = this.types.get(TYPE_ID); + int key = 1; + int correlationMetadata = 2; + return SenderRecord.create(new ProducerRecord<>(infoType.getKafkaInputTopic(), key, data), correlationMetadata); + } + + private void sendDataToStream(Flux> dataToSend) { + final KafkaSender sender = KafkaSender.create(senderOptions()); + + sender.send(dataToSend) // + .doOnError(e -> logger.error("Send failed", e)) // + .blockLast(); + + sender.close(); + + } + + private void verifiedReceivedByConsumer(String... strings) { + ConsumerController.TestResults consumer = this.consumerController.testResults; + await().untilAsserted(() -> assertThat(consumer.receivedBodies.size()).isEqualTo(strings.length)); + for (String s : strings) { + assertTrue(consumer.hasReceived(s)); + } + } + + @Test + void simpleCase() throws InterruptedException { + final String JOB_ID = "ID"; + + // Register producer, Register types + await().untilAsserted(() -> assertThat(icsSimulatorController.testResults.registrationInfo).isNotNull()); + assertThat(icsSimulatorController.testResults.registrationInfo.supportedTypeIds).hasSize(this.types.size()); + + this.icsSimulatorController.addJob(consumerJobInfo(null, Duration.ZERO, 0, 1), JOB_ID, restClient()); + await().untilAsserted(() -> assertThat(this.jobs.size()).isEqualTo(1)); + + Thread.sleep(4000); + var dataToSend = Flux.just(senderRecord("Message")); + sendDataToStream(dataToSend); + + verifiedReceivedByConsumer("Message"); + + this.icsSimulatorController.deleteJob(JOB_ID, restClient()); + + await().untilAsserted(() -> assertThat(this.jobs.size()).isZero()); + await().untilAsserted(() -> assertThat(this.kafkaTopicConsumers.getConsumers().keySet()).isEmpty()); + } + + @Test + void kafkaIntegrationTest() throws Exception { + final String JOB_ID1 = "ID1"; + final String JOB_ID2 = "ID2"; + + // Register producer, Register types + await().untilAsserted(() -> assertThat(icsSimulatorController.testResults.registrationInfo).isNotNull()); + assertThat(icsSimulatorController.testResults.registrationInfo.supportedTypeIds).hasSize(this.types.size()); + + // Create two jobs. One buffering and one with a filter + this.icsSimulatorController.addJob(consumerJobInfo(null, Duration.ofMillis(400), 10, 20), JOB_ID1, + restClient()); + this.icsSimulatorController.addJob(consumerJobInfo("^Message_1$", Duration.ZERO, 0, 1), JOB_ID2, restClient()); + + await().untilAsserted(() -> assertThat(this.jobs.size()).isEqualTo(2)); + + Thread.sleep(2000); + var dataToSend = Flux.range(1, 3).map(i -> senderRecord("Message_" + i)); // Message_1, Message_2 etc. + sendDataToStream(dataToSend); + + verifiedReceivedByConsumer("Message_1", "[\"Message_1\", \"Message_2\", \"Message_3\"]"); + + // Delete the jobs + this.icsSimulatorController.deleteJob(JOB_ID1, restClient()); + this.icsSimulatorController.deleteJob(JOB_ID2, restClient()); + + await().untilAsserted(() -> assertThat(this.jobs.size()).isZero()); + await().untilAsserted(() -> assertThat(this.kafkaTopicConsumers.getConsumers().keySet()).isEmpty()); + } + + @Test + void kafkaIOverflow() throws InterruptedException { + final String JOB_ID1 = "ID1"; + final String JOB_ID2 = "ID2"; + + // Register producer, Register types + await().untilAsserted(() -> assertThat(icsSimulatorController.testResults.registrationInfo).isNotNull()); + assertThat(icsSimulatorController.testResults.registrationInfo.supportedTypeIds).hasSize(this.types.size()); + + // Create two jobs. + this.icsSimulatorController.addJob(consumerJobInfo(null, Duration.ofMillis(400), 1000, 1), JOB_ID1, + restClient()); + this.icsSimulatorController.addJob(consumerJobInfo(null, Duration.ZERO, 0, 1), JOB_ID2, restClient()); + + await().untilAsserted(() -> assertThat(this.jobs.size()).isEqualTo(2)); + + var dataToSend = Flux.range(1, 1000000).map(i -> senderRecord("Message_" + i)); // Message_1, Message_2 etc. + sendDataToStream(dataToSend); // this should overflow + + KafkaJobDataConsumer consumer = kafkaTopicConsumers.getConsumers().get(TYPE_ID).iterator().next(); + await().untilAsserted(() -> assertThat(consumer.isRunning()).isFalse()); + this.consumerController.testResults.reset(); + + this.icsSimulatorController.deleteJob(JOB_ID2, restClient()); // Delete one job + kafkaTopicConsumers.restartNonRunningTopics(); + Thread.sleep(1000); // Restarting the input seems to take some asynch time + + dataToSend = Flux.just(senderRecord("Howdy\"")); + sendDataToStream(dataToSend); + + verifiedReceivedByConsumer("[\"Howdy\\\"\"]"); + + // Delete the jobs + this.icsSimulatorController.deleteJob(JOB_ID1, restClient()); + this.icsSimulatorController.deleteJob(JOB_ID2, restClient()); + + await().untilAsserted(() -> assertThat(this.jobs.size()).isZero()); + await().untilAsserted(() -> assertThat(this.kafkaTopicConsumers.getConsumers().keySet()).isEmpty()); + } + +} diff --git a/src/test/resources/test_application_configuration.json b/src/test/resources/test_application_configuration.json new file mode 100644 index 0000000..32e6c32 --- /dev/null +++ b/src/test/resources/test_application_configuration.json @@ -0,0 +1,14 @@ +{ + "types": [ + { + "id": "DmaapInformationType", + "dmaapTopicUrl": "/dmaap-topic-1", + "useHttpProxy": false + }, + { + "id": "KafkaInformationType", + "kafkaInputTopic": "TutorialTopic", + "useHttpProxy": false + } + ] +} \ No newline at end of file diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..2705e16 --- /dev/null +++ b/tox.ini @@ -0,0 +1,37 @@ +# ================================================================================== +# Copyright (c) 2020 Nordix +# +# 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 +# +# http://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. +# ================================================================================== + +# documentation only +[tox] +minversion = 2.0 +envlist = + docs, + docs-linkcheck, +skipsdist = true + +[testenv:docs] +basepython = python3 +deps = -r{toxinidir}/docs/requirements-docs.txt + +commands = + sphinx-build -W -b html -n -d {envtmpdir}/docs/doctrees ./docs/ {toxinidir}/docs/_build/html + echo "Generated docs available in {toxinidir}/docs/_build/html" +whitelist_externals = echo + +[testenv:docs-linkcheck] +basepython = python3 +deps = -r{toxinidir}/docs/requirements-docs.txt +commands = sphinx-build -W -b linkcheck -d {envtmpdir}/doctrees ./docs/ {toxinidir}/docs/_build/linkcheck -- 2.16.6