├── .github ├── ISSUE_TEMPLATE │ ├── Bug_report.md │ └── Feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ └── build.yml ├── .gitignore ├── .mvn ├── jvm.config └── wrapper │ ├── maven-wrapper.jar │ └── maven-wrapper.properties ├── .sdkmanrc ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE.txt ├── Makefile ├── README.md ├── debian ├── changelog ├── compat ├── control ├── dirs ├── postinst ├── postrm └── rules ├── docs ├── concepts.md ├── images │ └── architecture_overview.png └── metrics.md ├── jolokia.properties.sample ├── pom.xml ├── service.conf.sample └── src ├── main ├── java │ └── io │ │ └── retel │ │ └── ariproxy │ │ ├── AriCommandMessage.java │ │ ├── KafkaConsumerActor.java │ │ ├── Main.java │ │ ├── boundary │ │ ├── callcontext │ │ │ ├── CallContextProvider.java │ │ │ └── api │ │ │ │ ├── CallContextLookupError.java │ │ │ │ ├── CallContextProvided.java │ │ │ │ ├── CallContextProviderMessage.java │ │ │ │ ├── CallContextRegistered.java │ │ │ │ ├── ProvideCallContext.java │ │ │ │ ├── ProviderPolicy.java │ │ │ │ ├── RegisterCallContext.java │ │ │ │ └── ReportHealth.java │ │ ├── commandsandresponses │ │ │ ├── AriCommandResponseProcessing.java │ │ │ ├── AriCommandResponseProcessor.java │ │ │ └── auxiliary │ │ │ │ ├── AriCommand.java │ │ │ │ ├── AriCommandEnvelope.java │ │ │ │ ├── AriCommandType.java │ │ │ │ ├── AriMessageEnvelope.java │ │ │ │ ├── AriMessageResource.java │ │ │ │ ├── AriMessageType.java │ │ │ │ ├── AriResource.java │ │ │ │ ├── AriResourceRelation.java │ │ │ │ ├── AriResourceType.java │ │ │ │ ├── AriResponse.java │ │ │ │ ├── CallContextAndCommandRequestContext.java │ │ │ │ ├── CallContextAndResourceId.java │ │ │ │ ├── CommandRequest.java │ │ │ │ ├── CommandResponseHandler.java │ │ │ │ └── ExtractorNotAvailable.java │ │ └── events │ │ │ ├── AriEventProcessing.java │ │ │ └── WebsocketMessageToProducerRecordTranslator.java │ │ ├── health │ │ ├── AriConnectionCheck.java │ │ ├── HealthService.java │ │ ├── KafkaConnectionCheck.java │ │ └── api │ │ │ ├── HealthReport.java │ │ │ └── HealthResponse.java │ │ ├── metrics │ │ └── Metrics.java │ │ └── persistence │ │ ├── CachedKeyValueStore.java │ │ ├── KeyValueStore.java │ │ ├── PerformanceMeteringKeyValueStore.java │ │ ├── PersistenceStore.java │ │ ├── PersistentKeyValueStore.java │ │ └── plugin │ │ ├── CassandraPersistenceStore.java │ │ ├── RedisPersistenceStore.java │ │ └── SQLitePersistenceStore.java └── resources │ ├── application.conf │ └── log4j2.xml └── test ├── java └── io │ └── retel │ └── ariproxy │ ├── ArchitectureTest.java │ ├── TestArchitectureTest.java │ ├── TestUtils.java │ ├── boundary │ ├── callcontext │ │ ├── CallContextProviderTest.java │ │ ├── MemoryKeyValueStore.java │ │ └── TestableCallContextProvider.java │ ├── commandsandresponses │ │ ├── AriCommandResponseProcessingTest.java │ │ ├── AriCommandResponseProcessorTest.java │ │ └── auxiliary │ │ │ ├── AriCommandTypeTest.java │ │ │ └── AriMessageTypeTest.java │ └── events │ │ ├── AriEventProcessingTest.java │ │ ├── StasisEvents.java │ │ └── WebsocketMessageToProducerRecordTranslatorITCase.java │ ├── health │ ├── KafkaConnectionCheckTest.java │ └── api │ │ ├── HealthReportTest.java │ │ └── HealthResponseTest.java │ └── persistence │ └── plugin │ ├── CassandraPersistenceStoreTest.java │ ├── InMemoryPersistenceStore.java │ ├── RedisPersistenceStoreTest.java │ └── SQLitePersistenceStoreTest.java └── resources ├── application.conf ├── log4j2.xml ├── messages ├── ari │ └── responses │ │ └── bridgeCreateResponse.json ├── commands │ ├── bridgeCreateCommandWithBody.json │ ├── channelAnswerCommand.json │ ├── channelAnswerCommandWithoutCommandId.json │ ├── channelDeleteWithReasonCommand.json │ └── channelPlaybackCommand.json ├── events │ ├── stasisStartEventWithCallContext.json │ └── stasisStartEventWithoutCallContext.json └── responses │ ├── bridgeCreateRequestFailedResponse.json │ ├── bridgeCreateResponseWithBody.json │ ├── channelAnswerResponse.json │ ├── channelAnswerResponseWithoutCommandId.json │ ├── channelDeleteWithReasonResponse.json │ └── channelPlaybackResponse.json ├── no_optionals.conf └── persistence └── cassandra.cql /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | # Bug Report 8 | 9 | corresponding commit-id: 10 | asterisk version: 11 | kafka version: 12 | java version: 13 | 14 | ## setup description 15 | 16 | ## failure description 17 | 18 | ## expected behaviour description 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature/Change request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | # Feature Request 8 | 9 | ## proposal 10 | 11 | #### current behaviour 12 | 13 | #### desired behaviour 14 | 15 | ## use case / why is this important? 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### required for all prs: 2 | - [ ] Signed the [retel.io CLA](https://github.com/retel-io/cla). 3 | 4 | ### required only for more thorough changes: 5 | - [ ] Made sure there is a corresponding ticket in the project's [issue tracker](https://github.com/retel-io/ari-proxy/issues). 6 | - [ ] Made sure the ticket has been discussed and prioritized by the team. 7 | - [ ] Has appropriate unit tests. 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "maven" 9 | directory: "/" 10 | ignore: 11 | - dependency-name: "com.typesafe.akka:*" 12 | schedule: 13 | interval: "daily" 14 | time: "10:00" 15 | timezone: "Europe/Berlin" 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Maven Build 2 | 3 | on: [push,pull_request] 4 | 5 | env: 6 | REGISTRY: ghcr.io 7 | IMAGE_NAME: ${{ github.repository }} 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - name: Set up JDK 22 17 | uses: actions/setup-java@v4 18 | with: 19 | distribution: 'zulu' 20 | java-version: '22' 21 | - name: Cache Maven packages 22 | uses: actions/cache@v4 23 | with: 24 | path: ~/.m2 25 | key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} 26 | restore-keys: ${{ runner.os }}-m2 27 | - name: Build with Maven 28 | run: mvn -B verify --file pom.xml 29 | publish-docker-image: 30 | runs-on: ubuntu-latest 31 | needs: test 32 | if: github.ref == 'refs/heads/master' 33 | steps: 34 | - uses: actions/checkout@v4 35 | with: 36 | fetch-depth: 0 37 | - name: Log in to the Container registry 38 | uses: docker/login-action@6d4b68b490aef8836e8fb5e50ee7b3bdfa5894f0 39 | with: 40 | registry: ${{ env.REGISTRY }} 41 | username: ${{ github.actor }} 42 | password: ${{ secrets.GITHUB_TOKEN }} 43 | - name: Extract metadata (tags, labels) for Docker 44 | id: meta 45 | uses: docker/metadata-action@418e4b98bf2841bd337d0b24fe63cb36dc8afa55 46 | with: 47 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 48 | - name: Build and push Docker image 49 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 50 | with: 51 | context: . 52 | push: true 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .idea/ 3 | target/ 4 | build/ 5 | .m2/ 6 | public/debian/ari-proxy* 7 | public/debian/debhelper-build-stamp 8 | public/debian/files 9 | !public/debian/ari-proxy@.service 10 | service.conf 11 | -------------------------------------------------------------------------------- /.mvn/jvm.config: -------------------------------------------------------------------------------- 1 | --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/retel-io/ari-proxy/79c982d2ae3d7b534a53807d9160d43bd0499378/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | distributionUrl=https://registry.sipgate.net/maven/org/apache/maven/apache-maven/3.9.5/apache-maven-3.9.5-bin.zip 18 | wrapperUrl=https://registry.sipgate.net/maven/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar 19 | -------------------------------------------------------------------------------- /.sdkmanrc: -------------------------------------------------------------------------------- 1 | java=22.0.1.fx-zulu 2 | maven=3.9.6 -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # ari-proxy Developer Guidelines 2 | These guidelines are meant to be a living document that should be changed and adapted as needed. We encourage changes that make it easier to achieve our goals in an efficient way. 3 | 4 | ## Steps for Contributing 5 | This is the process for committing code to the ari-proxy project. There are of course exceptions to these rules, for example minor changes to comments and documentation, fixing a broken build etc. 6 | 7 | 1. Make sure you have signed the [retel.io CLA](https://github.com/retel-io/cla). 8 | 2. Before starting to work on a feature or a fix, it's good practice to ensure that: 9 | 1. There is a ticket for your work in the project's [issue tracker](https://github.com/retel-io/ari-proxy/issues); 10 | 2. The ticket has been discussed and prioritized by the team. 11 | 3. Fork the project and perform your work in your own git branch (that is, use the [GitHub Flow](https://guides.github.com/introduction/flow/)). Please make sure you adhere to the [Semantic Commit Messages](https://seesparkbox.com/foundry/semantic_commit_messages) format. 12 | 4. When the feature or fix is completed you should open a [Pull Request](https://help.github.com/articles/using-pull-requests) on GitHub. 13 | 5. The Pull Request will then be reviewed. 14 | 6. After the review, you should resolve issues brought up by the reviewers as needed, iterating until the reviewers give their thumbs up. 15 | 7. Once the code has passed review the Pull Request can be merged. 16 | 17 | ## Code style 18 | 19 | We use [spotless](https://github.com/diffplug/spotless) to gradually enforce [Google Java Style](https://google.github.io/styleguide/javaguide.html). Your build will fail, if changed files do not comply with the formatting guidelines. To automatically format your changed files correctly, run ` mvn spotless:apply`. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:22-jdk-slim AS build 2 | 3 | ARG MAVEN_VERSION=3.9.6 4 | RUN apt-get update \ 5 | && apt-get install -y wget \ 6 | && wget https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz \ 7 | && tar -xvzf apache-maven-${MAVEN_VERSION}-bin.tar.gz -C /opt \ 8 | && ln -s /opt/apache-maven-${MAVEN_VERSION}/bin/mvn /usr/bin/mvn \ 9 | && rm apache-maven-${MAVEN_VERSION}-bin.tar.gz \ 10 | && apt-get clean 11 | 12 | WORKDIR /usr/src/app/ 13 | 14 | COPY ./pom.xml ./pom.xml 15 | RUN --mount=type=cache,id=maven,target=/root/.m2 mvn dependency:go-offline -B 16 | 17 | COPY ./src ./src 18 | RUN --mount=type=cache,id=maven,target=/root/.m2 mvn package -DskipTests 19 | 20 | FROM azul/zulu-openjdk-alpine:22 21 | COPY --from=build /usr/src/app/target/ari-proxy-1.3.0-fat.jar /usr/app/ari-proxy.jar 22 | ENTRYPOINT ["java","-Dconfig.file=/usr/app/config/ari-proxy.conf","-jar","/usr/app/ari-proxy.jar"] 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: deb mv_build 2 | 3 | deb: inject_maven_settings 4 | @echo '[INFO] Running dpkg-buildpackage...' 5 | dpkg-buildpackage -uc -us -rfakeroot 6 | @echo '[INFO] Done.' 7 | 8 | mv_build: deb 9 | @echo '[INFO] Moving deb to build folder...' 10 | mkdir -p build/ 11 | mv ../ari-proxy_*.deb build/ 12 | rm -f ../ari-proxy_* 13 | @echo '[INFO] Done.' 14 | 15 | inject_maven_settings: 16 | @echo '[INFO] Injecting maven settings...' 17 | mkdir -p $(PWD)/.m2/ 18 | cp $(PWD)/maven_settings.xml $(PWD)/.m2/settings.xml 19 | @echo '[INFO] Done.' 20 | 21 | clean: 22 | @echo '[INFO] Cleaning up dpkg build...' 23 | rm -rf $(PWD)/debian/ari-proxy/ 24 | rm -f $(PWD)/debian/ari-proxy.substvars 25 | rm -f $(PWD)/debian/debhelper-build-stamp 26 | rm -f $(PWD)/debian/files 27 | rm -rf $(PWD)/.m2 28 | rm -rf $(PWD)/build 29 | @echo '[INFO] Done.' 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ari-proxy 2 | Ari-proxy connects [Asterisk](https://www.asterisk.org/), an open source communication server, to the [Apache Kafka](https://kafka.apache.org/intro) distributed streaming platform. 3 | 4 | 5 | | :warning: WARNING: THIS REPOSITORY MIGHT INTRODUCE BREAKING CHANGES. | 6 | |:----------------------------------------------------------------------| 7 | 8 | ## Table of contents 9 | 1. [Abstract](#abstract) 10 | 2. [Getting started](#getting-started) 11 | 3. [Metrics](#metrics) 12 | 4. [Compatibility](#compatibility) 13 | 5. [Contributing & feedback](#contributing--feedback) 14 | 6. [Credit & License](#credit--license) 15 | 7. [Acknowledgements](#acknowledgements) 16 | 17 | ## Abstract 18 | The motivation to create ari-proxy arose from the need to build distributed and resilient telephony services scaling up to millions of active users. Ari-proxy makes use of Kafka’s built-in routing concepts to ensure consistency of message streams and proper dispatching to the *call-controller*, the application implementing the service logic. 19 | 20 | ![Architecture Overview](docs/images/architecture_overview.png "Architecture Overview") 21 | 22 | Please see [docs/concepts.md](/public/docs/concepts.md) for details on the concepts of message routing and session mapping. 23 | 24 | ## Getting started 25 | In order to operate ari-proxy, make sure you have a running instance of both Asterisk and Kafka server. 26 | 27 | #### Prerequisites 28 | ari-proxy is written in Java, so you should [install and setup Java](https://www.java.com/en/download/help/download_options.xml) before continuing. 29 | The project is managed by maven, which requires you to [install maven](https://maven.apache.org/install.html) as well. 30 | 31 | #### Building 32 | Build the fat jar in `target/`: 33 | ```bash 34 | mvn package 35 | ``` 36 | 37 | #### Configuration 38 | ari-proxy expects the following configuration files, which should be passed to the jvm when running the fat-jar: 39 | 40 | | config | optional | purpose | 41 | | -------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | 42 | | service.conf | no | configure the service, see our template: [service.conf.sample](service.conf.sample) | 43 | | log4j2.xml | yes | configure logging (if not specified, [a bundled config](/src/main/resources/log4j2.xml) will be used logging to STDOUT only) | 44 | | jolokia.properties | no | configure jolokia agent properties, see our template: [jolokia.properties.sample](jolokia.properties.sample) | 45 | 46 | #### Running 47 | Run the fat jar: 48 | ```bash 49 | java -Dconfig.file=/path/to/service.conf [-Dlog4j.configurationFile=/path/to/log4j2.xml] -jar target/ari-proxy-1.3.0-fat.jar 50 | ``` 51 | 52 | #### Persistence-store 53 | 54 | There are two ways to persist the in-memory data storage (Asterisk Object ID -> Kafka Routing Key). 55 | 56 | First there is Redis (default). The redis needs to be configured in service.conf file in order to be able to connect. Also a keyspace needs to be configured. 57 | Second possibility to store the data is Cassandra. This is a more robust but more complex configuration as it provides HA and better scalability. You need to configure the Cassandra nodes in the "datastax" section of "service.conf" 58 | 59 | In order to choose one option you need to enable the one or the other backend by set the parameter `persistence-store` to one of these values: 60 | 61 | - "io.retel.ariproxy.persistence.plugin.CassandraPersistenceStore" 62 | 63 | - "io.retel.ariproxy.persistence.plugin.RedisPersistenceStore" 64 | 65 | - "io.retel.ariproxy.persistence.plugin.SQLitePersistenceStore" 66 | 67 | 68 | In case you want to use Cassandra you need to create a keyspace and table in Cassandra. This snippet might help to create one. (please adapt replication factor and names according your setup) 69 | 70 | ```cql 71 | CREATE KEYSPACE retel with replication = {'class':'SimpleStrategy','replication_factor':1}; 72 | 73 | USE retel; 74 | 75 | CREATE TABLE retel ( 76 | "key" text primary key, 77 | "value" text 78 | ); 79 | ``` 80 | 81 | Hint: do not forget some kind of housekeeping by adding TTL or a cleanup job. 82 | 83 | ## Monitoring 84 | ### Health-Check 85 | There are three routes to check for service health: 86 | - `/health` 87 | - `/health/smoke` 88 | - `/health/backing-services` 89 | 90 | `/health` is meant to check if the service itself is healthy in terms of JVM,Akka etc. settings. Yet to be implemented correctly. 91 | 92 | `/health/smoke` is used for deployment to check if the service is started. Only checks for open port. 93 | 94 | `/health/backing-services` checks all services that are necessary for running the application e.g. Kafka, Asterisk, Redis/Cassandra. 95 | 96 | ### Metrics 97 | Ari-proxy provides service specific metrics using the [micrometer framework](http://micrometer.io) which are available via JMX or HTTP. 98 | 99 | For further details see: [Metrics](docs/metrics.md) 100 | 101 | 102 | ## Compatibility 103 | We aim for compatibility with the latest stable release of 104 | - [Java OpenJDK 22](https://openjdk.java.net/projects/jdk/22/) 105 | - [Asterisk](https://wiki.asterisk.org/wiki/display/AST/Asterisk+Versions) 106 | - the utilized [Akka Modules](https://akka.io/docs/) 107 | 108 | ## Contributing & feedback 109 | To report a bug or make a request for new features, use the [Issues Page](https://github.com/retel-io/ari-proxy/issues) in the ari-proxy Github project. 110 | We welcome any contributions. Please see the [Developer Guidelines](/public/CONTRIBUTING.md) for instructions on how to submit changes. 111 | 112 | ## Credit & License 113 | ari-proxy is maintained by the folks at [sipgate](https://www.sipgate.de) and licensed under the terms of the [AGPL license](/public/LICENSE.txt). 114 | 115 | Maintainers of this repository: 116 | 117 | - Jöran [@vinzens](https://github.com/vinzens) 118 | - Maya [@ironmaya](https://github.com/ironmaya) 119 | - Mia [@mia-krause](https://github.com/mia-krause) 120 | - Sven [@SvenKube](https://github.com/SvenKube) 121 | - Max [@IllTemperedMax](https://github.com/IllTemperedMax) 122 | 123 | Please refer to the Git commit log for a complete list of contributors. 124 | 125 | ## Acknowledgements 126 | ari-proxy is not the first of its kind. This project was inspired by the concepts underlying both [go-ari-proxy by N-Visible](https://github.com/nvisibleinc/go-ari-proxy) as well as [ari-proxy by CyCore Systems](https://github.com/CyCoreSystems/ari-proxy). 127 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | ari-proxy (1.3.0) unstable; urgency=low 2 | 3 | * feat: include related resource types and ids in responses and events 4 | * feat: include command request in responses 5 | 6 | -- re-tel Wed, 18 Nov 2020 07:00:00 +0100 7 | 8 | ari-proxy (1.2.1) unstable; urgency=low 9 | 10 | * fix: always pass call-context to AriMessageEnvelope 11 | 12 | -- re-tel Mon, 25 Feb 2019 07:00:00 +0100 13 | 14 | ari-proxy (1.2.0) unstable; urgency=low 15 | 16 | * feat: route by call-context 17 | * feat: implement restart-safety 18 | 19 | -- re-tel Fr, 22 Feb 2019 07:00:00 +0100 20 | 21 | ari-proxy (1.1.0) unstable; urgency=low 22 | 23 | * feat: treat all nested json objects as such 24 | 25 | -- re-tel Wed, 12 Dec 2018 07:00:00 +0100 26 | 27 | ari-proxy (1.0.1) unstable; urgency=low 28 | 29 | * Introduce commandId for ansychronous commands via kafka 30 | 31 | -- re-tel Thu, 29 Nov 2018 19:00:00 +0100 32 | 33 | ari-proxy (1.0.0) unstable; urgency=low 34 | 35 | * makes command-response processor more resilient 36 | * adjust configuration structure 37 | 38 | -- re-tel Thu, 22 Nov 2018 15:19:34 +0100 39 | 40 | ari-proxy (0.9.0) unstable; urgency=low 41 | 42 | * Initial release. 43 | 44 | -- re-tel Thu, 26 Sep 2018 10:42:55 +0100 45 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: ari-proxy 2 | Section: telephony 3 | Priority: optional 4 | Build-Depends: java17-sdk-headless, debhelper, dh-systemd 5 | Maintainer: re-tel 6 | Uploaders: re-tel 7 | Standards-Version: 3.9.4 8 | 9 | Package: ari-proxy 10 | Architecture: all 11 | Depends: java17-runtime-headless, adduser, bash 12 | Description: asterisk rest interface proxy developed by sipgate 13 | -------------------------------------------------------------------------------- /debian/dirs: -------------------------------------------------------------------------------- 1 | usr/share/java/ari-proxy 2 | var/log/ari-proxy 3 | etc/ari-proxy 4 | -------------------------------------------------------------------------------- /debian/postinst: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | case "$1" in 4 | configure) 5 | adduser \ 6 | --system \ 7 | --group \ 8 | --no-create-home \ 9 | --disabled-password \ 10 | --disabled-login \ 11 | --home /usr/share/java/ari-proxy \ 12 | --quiet \ 13 | ari-proxy 14 | ;; 15 | esac 16 | 17 | chown root:root /usr/share/java/ari-proxy/ari-proxy.jar 18 | chmod 0644 /usr/share/java/ari-proxy/ari-proxy.jar 19 | 20 | chown -R ari-proxy:ari-proxy /var/log/ari-proxy 21 | chmod 0755 /var/log/ari-proxy 22 | -------------------------------------------------------------------------------- /debian/postrm: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | case "$1" in 4 | remove) 5 | deluser ari-proxy 6 | ;; 7 | purge) 8 | test -d /etc/ari-proxy && rm -rfv /etc/ari-proxy 9 | test -d /var/log/ari-proxy && rm -rfv /var/log/ari-proxy 10 | ;; 11 | esac 12 | 13 | exit 0; 14 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | export DH_VERBOSE=1 4 | 5 | USR_INSTALL=root 6 | GRP_INSTALL=root 7 | PACKAGE=$(shell dh_listpackages) 8 | DEB_PREFIX=debian/$(PACKAGE) 9 | SERVICE_NAME=ari-proxy 10 | SERVICE_VERSION=1.3.0 11 | SERVICE_USER=$(SERVICE_NAME) 12 | SERVICE_GROUP=$(SERVICE_NAME) 13 | SERVICE_LIB_DIR=$(DEB_PREFIX)/usr/share/java/$(SERVICE_NAME) 14 | 15 | override_dh_auto_clean: 16 | override_dh_auto_build: 17 | override_dh_fixperms: 18 | override_dh_usrlocal: 19 | override_dh_strip_nondeterminism: 20 | 21 | override_dh_install: copy_package 22 | 23 | %: 24 | dh $@ 25 | 26 | ########## 27 | # Helper # 28 | ########## 29 | 30 | build_package: 31 | ./mvnw -B -Dmaven.repo.local=$(PWD)/.m2 -Dskip-spotless=true clean verify 32 | 33 | setup_service_lib_dir: 34 | mkdir -p $(SERVICE_LIB_DIR) 35 | 36 | copy_package: build_package setup_service_lib_dir 37 | cp target/$(SERVICE_NAME)-$(SERVICE_VERSION)-fat.jar $(SERVICE_LIB_DIR)/$(SERVICE_NAME).jar 38 | -------------------------------------------------------------------------------- /docs/concepts.md: -------------------------------------------------------------------------------- 1 | The [retel.io project](http://retel.io) develops concepts and software componentens for building carrier grade, distributed and resilient telephony services scaling up to millions of active users. 2 | 3 | The scalability concept of retel.io is based on the decoupling of signalling and media-management using Apache Kafka as a message broker. Service logic implementation, further referenced as "call-controller", and asterisk instances may therefore be scaled and deployed independently. As ari-proxy takes care of mapping ARI communication to the appropriate resources, there is a one-to-one mapping of asterisk Stasis app and ari-proxy (see [service partitioning](#service-partitioning) for more details). 4 | 5 | ![Architecture Overview](images/architecture_overview.png "Architecture Overview") 6 | 7 | # message processing 8 | This section describes in detail, how ari-proxy handles different types of messages that are exchanged in a retel.io call-application setup: 9 | 10 | ## events 11 | The term *events* is used for messages, that are generated by Asterisk, provided via [asterisk's ARI](https://wiki.asterisk.org/wiki/display/AST/Getting+Started+with+ARI) and describe actual events on asterisk resources. Events are consumed by ari-proxy via websocket. The provided JSON structure of an ARI-event is further encapsulated in an envelope structure to allow for transportation of additional meta-data. To map messages to the appropriate resources, ari-proxy uses an identifier called "callcontext" that is derived from asterisk channel information. All ari-proxy instances in a retel.io-setup publish events to the same *events-and-responses topic*. As Kafka dispatches messages to consumers based on the *routing key*, it ensures that all messages of a callcontext are processed by the same consumer (this again is a simplification; see [resilience](#resilience) for further details), ensuring the critical ordering of messages. 12 | 13 | Example of an encapsulated ARI event (see `payload` field) as it is published to Kafka on the *events-and-responses topic*: 14 | ```json 15 | { 16 | "type": "STASIS_START", 17 | "callContext": "CALL_CONTEXT_PROVIDED", 18 | "commandsTopic": "ari-callcontroller-demo-commands-000000000002", 19 | "commandId": null, 20 | "commandRequest": null, 21 | "resources": { 22 | "type": "CHANNEL", 23 | "id": "1532965104.0" 24 | }, 25 | "payload": { 26 | "type": "StasisStart", 27 | "timestamp": "2018-08-27T16:19:36.049+0200", 28 | "args": [], 29 | "channel": { 30 | "id": "1535379576.296", 31 | "name": "PJSIP/proxy-0000009a", 32 | "state": "Ring", 33 | "caller": { 34 | "name": "", 35 | "number": "555-1234567" 36 | }, 37 | "connected": { 38 | "name": "", 39 | "number": "" 40 | }, 41 | "accountcode": "", 42 | "dialplan": { 43 | "context": "default", 44 | "exten": "10000", 45 | "priority": 3 46 | }, 47 | "creationtime": "2018-08-27T16:19:36.040+0200", 48 | "language": "en", 49 | "channelvars": {} 50 | }, 51 | "asterisk_id": "00:00:00:00:00:02", 52 | "application": "callcontroller-demo" 53 | } 54 | } 55 | ``` 56 | 57 | ## commands 58 | The term *commands* refers to operations requested by a call-controller instance to a specific asterisk instance and resource. Every ari-proxy is provided with a unique *commands topic* to which it subscribes. The envelope-structure that encapsulates each event contains the appropriate *commands topic* which can be used by the consuming call-controller to publish its commands to the appropriate Kafka topic. Besides the instance specific *commands topic* which allows the call-controller to react to events, all ari-proxy instances subscribe to a generic commands topic (**Not yet implemented!**). Messages to this generic command topic are distributes round-robin by Kafka, allowing a call-controller instance to initiate a session (e.g. starting a new call). Similar to events, commands are encapsulated in an envelope structure: 59 | 60 | ```json 61 | { 62 | "callContext": "CALL_CONTEXT", 63 | "commandId": "COMMAND_ID", 64 | "ariCommand": { 65 | "method": "DELETE", 66 | "url": "/ari/channels/1538054167.95404" 67 | } 68 | } 69 | ``` 70 | 71 | When processing a command that creates a new resource in asterisk, ari-proxy registers the unique identifier (e.g. *RecordingName* or *PlaybackId*) provided with the command, mapping it to the related call-context. This allows events that will be generated by those resources to be mapped to the originating call-context. 72 | 73 | ## responses 74 | *responses* carry the ARI response to a command. ari-proxy encapsulates the HTTP response to the command's HTTP request and publishes it on the *events-and-responses topic*. Based on the mapping of command message to callcontext, ari-proxy determines the call-context for a response message to ensure proper routing to the sending call-controller instance. 75 | 76 | Example of an encapsulated ARI response as it is published to Kafka on the *events-and-responses topic*: 77 | ```json 78 | { 79 | "type": "RESPONSE", 80 | "callContext": "CALL_CONTEXT", 81 | "commandId": "COMMAND_ID", 82 | "commandsTopic": "ari-callcontroller-demo-commands-000000000002", 83 | "commandRequest": { 84 | "method": "DELETE", 85 | "url": "/ari/channels/1538054167.95404" 86 | }, 87 | "resources": [ 88 | { 89 | "type": "CHANNEL", 90 | "id": "1538054167.95404" 91 | } 92 | ], 93 | "payload": { 94 | "status_code": 204 95 | } 96 | } 97 | ``` 98 | 99 | # beyond simplification 100 | ## service partitioning 101 | A single asterisk instance may spawn multiple Stasis applications. This may be used for a modular service setup or to share asterisk resources between completely independent services. Ari-proxy supports this type of partitioning. Multiple ari-proxy instances may be dedicated to a single asterisk instance, by dispatching channels to different Stasis applications. 102 | 103 | ## resilience 104 | While the use of the call-context as the Kafka *routing key* ensures ordering of message-processing this concept also allows for automatic reallocation of a callcontext to a responsible call-controller. When a call-controller instance crashes or is intentionally shut down, Kafka's built-in reallocation of *routing key* to consumer ensures that a different instance will take over the handling of the running session. This implies that potential state information is persisted and shared between call-controller instances. 105 | 106 | While the call-context is normally generated by ARI-proxy when a StasisStart event is generated by Asterisk, there are situations where it is necessary to specify the call-context from the Asterisk dialplan. This would be the case, for example, when it is necessary that a group of calls be handled by the same Call controller instance. This would be the case with call conferencing or call queue applications, where centralized management of the conference room or call queue is necessary. 107 | 108 | To achieve this, ARI-proxy looks for a channel variable named CALL_CONTEXT, that may be published in the channel/channelvars section of the StasisStart event. To enable the channelvars option with Asterisk, it is necessary to add the channelvars option to the ari.conf file, specifying the channel variables that need to be published, making sure to add CALL_CONTEXT to the list. 109 | -------------------------------------------------------------------------------- /docs/images/architecture_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/retel-io/ari-proxy/79c982d2ae3d7b534a53807d9160d43bd0499378/docs/images/architecture_overview.png -------------------------------------------------------------------------------- /docs/metrics.md: -------------------------------------------------------------------------------- 1 | # Metrics 2 | Ari-proxy provides service specific metrics using the [micrometer framework](http://micrometer.io) which are available via JMX or HTTP. 3 | Both ways provide the same data. 4 | 5 | Metrics via HTTP are in Prometheus format and are provided via the route `/metrics` on the port configured by `service.httpport` in the service config file. 6 | 7 | 8 | ## Meter descriptions 9 | 10 | All meters can be tagged with common tags, which can be defined in the config. 11 | 12 | * **ari-proxy.persistence.write.duration**: Measures the time it takes to persist data in the persistence store backend. 13 | * **ari-proxy.cache.read.attempts**: Counts how often the local cache is called to lookup a call context. 14 | * **ari-proxy.cache.read.misses**: Increases every time a lookup for call context in local cache fails and it has to be retrieved from the persistence backend 15 | * **ari-proxy.cache.read.errors**: How often a call context lookup failed 16 | * **ari-proxy.processor.restarts**: Tagged by `processorType`. Increases every time the command or event processing stream restarts because of errors. 17 | * **ari-proxy.persistence.read.errors**: Errors when trying to read from persistence store 18 | * **ari-proxy.persistence.write.duration**: Duration of writing to persistence store 19 | * **ari-proxy.outgoing.requests**: Tagged by `method` and `path`. Counts outgoing ARI requests. 20 | * **ari-proxy.outgoing.requests.errors**: Tagged by `method` and `path`. Counts errors of outgoing ARI requests. 21 | * **ari-proxy.outgoing.requests.duration**: Tagged by `method` and `path`. Measures the duration of outgoing ARI requests. 22 | * **ari-proxy.events**: Tagged by `eventType` and `resourceType`. Counts ARI events of the respective type. 23 | * **ari-proxy.backing_service.availability**: Tagged by `backing_service`. Represents the health of each backing service as binary numeric value; 1=available, 0=unavailable. 24 | -------------------------------------------------------------------------------- /jolokia.properties.sample: -------------------------------------------------------------------------------- 1 | # Sample file to configure the jolokia agent. For options, see: 2 | # https://jolokia.org/reference/html/agents.html#agents-jvm 3 | port=8778 4 | -------------------------------------------------------------------------------- /service.conf.sample: -------------------------------------------------------------------------------- 1 | include "application" // note: include default settings 2 | 3 | service { 4 | stasis-app = "application-name" 5 | name = "ari-proxy-for-some-application" // optional, default: ari-proxy 6 | httpport = 9000 // optional, default: 8080 7 | 8 | asterisk { 9 | user = "asterisk" // optional, default: asterisk 10 | password = "asterisk" // optional, default: asterisk 11 | server = "localhost:8088" 12 | } 13 | 14 | kafka { 15 | bootstrap-servers = "localhost:9092" 16 | 17 | // Optionally set the SASL_SSL security protocol and provide user and password to enable kafka authentication 18 | // security { 19 | // protocol = "SASL_SSL" 20 | // user = "" 21 | // password = "" 22 | // } 23 | 24 | consumer-group = "ari-proxy" // optional, default: ari-proxy 25 | commands-topic = "ari-commands-topic" 26 | events-and-responses-topic = "ari-eventsandresponses-topic" 27 | } 28 | 29 | // persistence-store: optional, defaults to using the redis persistence store 30 | // possible values: 31 | // - "io.retel.ariproxy.persistence.plugin.CassandraPersistenceStore" 32 | // - "io.retel.ariproxy.persistence.plugin.RedisPersistenceStore" 33 | // - "io.retel.ariproxy.persistence.plugin.SQLitePersistenceStore" 34 | // 35 | // use CassandraPersistenceStore and the datastax-driver config below, if you want to use cassandra as persistent backend 36 | persistence-store = "io.retel.ariproxy.persistence.plugin.RedisPersistenceStore" 37 | 38 | // regis persistence plugin 39 | redis { 40 | host = 127.0.0.1 41 | port = 6379 42 | db = 0 43 | //ttl = 6h 44 | } 45 | 46 | // SQLite persistence plugin 47 | //sqlite { 48 | // url = "jdbc:sqlite::memory:" 49 | // ttl = 6h 50 | //} 51 | 52 | 53 | } 54 | 55 | // for use with cassandra persistence store 56 | 57 | //datastax-java-driver { 58 | // basic { 59 | // contact-points = [ "localhost:9042" ] 60 | // session-keyspace = retel 61 | // load-balancing-policy.local-datacenter = dc1 62 | // } 63 | // advanced.reconnection-policy { 64 | // class = ExponentialReconnectionPolicy 65 | // base-delay = 1 second 66 | // max-delay = 60 seconds 67 | // } 68 | // advanced.auth-provider { 69 | // class = PlainTextAuthProvider 70 | // username = cassandra 71 | // password = cassandra 72 | // } 73 | //} 74 | 75 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/AriCommandMessage.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy; 2 | 3 | import akka.actor.typed.ActorRef; 4 | import akka.pattern.StatusReply; 5 | 6 | public class AriCommandMessage { 7 | private final String commandJson; 8 | private final ActorRef> replyTo; 9 | 10 | public AriCommandMessage(final String commandJson, final ActorRef> replyTo) { 11 | this.commandJson = commandJson; 12 | this.replyTo = replyTo; 13 | } 14 | 15 | public String getCommandJson() { 16 | return commandJson; 17 | } 18 | 19 | public ActorRef> getReplyTo() { 20 | return replyTo; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/KafkaConsumerActor.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy; 2 | 3 | import static io.confluent.parallelconsumer.ParallelConsumerOptions.ProcessingOrder.KEY; 4 | 5 | import akka.actor.typed.ActorRef; 6 | import akka.actor.typed.Behavior; 7 | import akka.actor.typed.PostStop; 8 | import akka.actor.typed.javadsl.*; 9 | import akka.pattern.StatusReply; 10 | import com.typesafe.config.Config; 11 | import io.confluent.parallelconsumer.ParallelConsumerOptions; 12 | import io.confluent.parallelconsumer.ParallelStreamProcessor; 13 | import java.time.Duration; 14 | import java.util.HashMap; 15 | import java.util.Map; 16 | import java.util.Set; 17 | import org.apache.kafka.clients.CommonClientConfigs; 18 | import org.apache.kafka.clients.admin.ScramMechanism; 19 | import org.apache.kafka.clients.consumer.Consumer; 20 | import org.apache.kafka.clients.consumer.ConsumerConfig; 21 | import org.apache.kafka.clients.consumer.ConsumerRecord; 22 | import org.apache.kafka.clients.consumer.KafkaConsumer; 23 | import org.apache.kafka.common.config.SaslConfigs; 24 | import org.apache.kafka.common.security.auth.SecurityProtocol; 25 | import org.apache.kafka.common.serialization.StringDeserializer; 26 | import org.slf4j.Logger; 27 | import org.slf4j.LoggerFactory; 28 | 29 | public final class KafkaConsumerActor extends AbstractBehavior { 30 | 31 | private static final Logger LOGGER = LoggerFactory.getLogger(KafkaConsumerActor.class); 32 | 33 | private final Config kafkaConfig; 34 | 35 | private final ParallelStreamProcessor streamProcessor; 36 | 37 | private KafkaConsumerActor( 38 | final ActorContext context, 39 | final Config kafkaConfig, 40 | final ActorRef commandResponseProcessor) { 41 | super(context); 42 | 43 | this.kafkaConfig = kafkaConfig; 44 | final ParallelConsumerOptions options = 45 | ParallelConsumerOptions.builder() 46 | .ordering(KEY) 47 | .maxConcurrency(this.kafkaConfig.getInt("parallel-consumer-max-concurrency")) 48 | .consumer(createConsumer()) 49 | .build(); 50 | 51 | streamProcessor = ParallelStreamProcessor.createEosStreamProcessor(options); 52 | 53 | LOGGER.debug( 54 | "Starting Kafka Consumer and subscribing to topic {}.", 55 | this.kafkaConfig.getString("commands-topic")); 56 | 57 | streamProcessor.subscribe(Set.of(this.kafkaConfig.getString("commands-topic"))); 58 | 59 | streamProcessor.poll( 60 | recordContexts -> { 61 | final ConsumerRecord singleConsumerRecord = 62 | recordContexts.getSingleConsumerRecord(); 63 | 64 | AskPattern.>ask( 65 | commandResponseProcessor, 66 | replyTo -> new AriCommandMessage(singleConsumerRecord.value(), replyTo), 67 | Duration.ofSeconds(1), 68 | context.getSystem().scheduler()) 69 | .thenAccept( 70 | (StatusReply reply) -> { 71 | if (reply.isError()) { 72 | LOGGER.error( 73 | "Error occurred during message processing. Committing offset anyway.", 74 | reply.getError()); 75 | } 76 | }); 77 | }); 78 | } 79 | 80 | public static Behavior create( 81 | final Config config, final ActorRef commandResponseProcessor) { 82 | return Behaviors.setup( 83 | context -> new KafkaConsumerActor(context, config, commandResponseProcessor)); 84 | } 85 | 86 | @Override 87 | public Receive createReceive() { 88 | return ReceiveBuilder.create() 89 | .onSignal( 90 | PostStop.class, 91 | param -> { 92 | LOGGER.info( 93 | "Received PostStop signal. Draining Kafka consumer. Messages left to process: {}", 94 | streamProcessor.workRemaining()); 95 | streamProcessor.closeDrainFirst(); 96 | 97 | return Behaviors.same(); 98 | }) 99 | .build(); 100 | } 101 | 102 | private Consumer createConsumer() { 103 | Map config = new HashMap<>(); 104 | config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); 105 | config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaConfig.getString("bootstrap-servers")); 106 | config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); 107 | config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); 108 | config.put(ConsumerConfig.GROUP_ID_CONFIG, kafkaConfig.getString("consumer-group")); 109 | 110 | if ("SASL_SSL".equals(kafkaConfig.getString("security.protocol"))) { 111 | config.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, SecurityProtocol.SASL_SSL.name()); 112 | config.put(SaslConfigs.SASL_MECHANISM, ScramMechanism.SCRAM_SHA_256.mechanismName()); 113 | config.put( 114 | SaslConfigs.SASL_JAAS_CONFIG, 115 | "org.apache.kafka.common.security.scram.ScramLoginModule required username=\"%s\" password=\"%s\";" 116 | .formatted( 117 | kafkaConfig.getString("security.user"), 118 | kafkaConfig.getString("security.password"))); 119 | } 120 | 121 | return new KafkaConsumer<>(config); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/callcontext/CallContextProvider.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.callcontext; 2 | 3 | import static java.util.concurrent.CompletableFuture.completedFuture; 4 | 5 | import akka.actor.typed.Behavior; 6 | import akka.actor.typed.PostStop; 7 | import akka.actor.typed.PreRestart; 8 | import akka.actor.typed.javadsl.Behaviors; 9 | import akka.pattern.StatusReply; 10 | import io.retel.ariproxy.boundary.callcontext.api.*; 11 | import io.retel.ariproxy.persistence.KeyValueStore; 12 | import java.util.Optional; 13 | import java.util.UUID; 14 | import java.util.concurrent.CompletableFuture; 15 | import java.util.concurrent.CompletionStage; 16 | import java.util.function.Function; 17 | import org.slf4j.Logger; 18 | import org.slf4j.LoggerFactory; 19 | 20 | public class CallContextProvider { 21 | 22 | private static final Logger LOGGER = LoggerFactory.getLogger(CallContextProvider.class); 23 | 24 | private static final String KEY_PREFIX = "ari-proxy:call-context-provider"; 25 | 26 | private CallContextProvider() { 27 | throw new IllegalStateException("Utility class"); 28 | } 29 | 30 | public static Behavior create() { 31 | return create(KeyValueStore.createDefaultStore()); 32 | } 33 | 34 | public static Behavior create( 35 | final KeyValueStore store) { 36 | return Behaviors.setup( 37 | context -> 38 | Behaviors.receive(CallContextProviderMessage.class) 39 | .onMessage(RegisterCallContext.class, msg -> registerCallContextHandler(store, msg)) 40 | .onMessage(ProvideCallContext.class, msg -> provideCallContextHandler(store, msg)) 41 | .onMessage(ReportHealth.class, msg -> handleReportHealth(store, msg)) 42 | .onSignal(PostStop.class, signal -> cleanup(store)) 43 | .onSignal(PreRestart.class, signal -> cleanup(store)) 44 | .build()); 45 | } 46 | 47 | private static Behavior registerCallContextHandler( 48 | final KeyValueStore store, final RegisterCallContext msg) { 49 | final String resourceId = msg.resourceId(); 50 | final String callContext = msg.callContext(); 51 | LOGGER.debug("Registering resourceId '{}' => callContext '{}'…", resourceId, callContext); 52 | 53 | store 54 | .put(withKeyPrefix(resourceId), callContext) 55 | .thenRunAsync( 56 | () -> 57 | LOGGER.debug( 58 | "Successfully registered resourceId '{}' => callContext '{}'", 59 | resourceId, 60 | callContext)) 61 | .exceptionallyAsync( 62 | error -> { 63 | LOGGER.error( 64 | "Failed to register resourceId '{}' => callContext '{}' with error: {}", 65 | resourceId, 66 | callContext, 67 | error.getMessage()); 68 | return null; 69 | }); 70 | 71 | return Behaviors.same(); 72 | } 73 | 74 | private static Behavior provideCallContextHandler( 75 | final KeyValueStore store, final ProvideCallContext msg) { 76 | LOGGER.debug("Looking up callContext for resourceId '{}'…", msg.resourceId()); 77 | 78 | final CompletableFuture> callContext = 79 | ProviderPolicy.CREATE_IF_MISSING.equals(msg.policy()) 80 | ? provideCallContextForCreateIfMissingPolicy(store, msg) 81 | : provideCallContextForLookupOnlyPolicy(store, msg); 82 | 83 | callContext.whenComplete( 84 | (cContext, error) -> { 85 | final StatusReply response; 86 | if (error != null) { 87 | if (error instanceof CallContextLookupError) { 88 | response = StatusReply.error(error); 89 | } else { 90 | response = 91 | StatusReply.error( 92 | String.format( 93 | "Unable to lookup call context for resource %s: %s", 94 | msg.resourceId(), error.getMessage())); 95 | } 96 | } else { 97 | if (cContext.isPresent()) { 98 | response = StatusReply.success(new CallContextProvided(cContext.get())); 99 | } else { 100 | response = 101 | StatusReply.error( 102 | String.format( 103 | "Unable to lookup call context for resource %s", msg.resourceId())); 104 | } 105 | } 106 | 107 | msg.replyTo().tell(response); 108 | }); 109 | 110 | return Behaviors.same(); 111 | } 112 | 113 | private static CompletableFuture> provideCallContextForLookupOnlyPolicy( 114 | final KeyValueStore store, final ProvideCallContext msg) { 115 | final String prefixedResourceId = withKeyPrefix(msg.resourceId()); 116 | return exceptionallyCompose( 117 | store.get(prefixedResourceId), 118 | error -> 119 | failedFuture( 120 | new CallContextLookupError( 121 | String.format( 122 | "Failed to lookup call context for resource id %s...", 123 | msg.resourceId())))) 124 | .toCompletableFuture(); 125 | } 126 | 127 | private static CompletableFuture> provideCallContextForCreateIfMissingPolicy( 128 | final KeyValueStore store, final ProvideCallContext msg) { 129 | final String resourceId = msg.resourceId(); 130 | final String prefixedResourceId = withKeyPrefix(resourceId); 131 | 132 | if (msg.maybeCallContextFromChannelVars().isDefined()) { 133 | final String callContext = 134 | new CallContextProvided(msg.maybeCallContextFromChannelVars().get()).callContext(); 135 | return store.put(prefixedResourceId, callContext).thenApply(done -> Optional.of(callContext)); 136 | } 137 | 138 | return store 139 | .get(prefixedResourceId) 140 | .thenCompose( 141 | maybeCallContextFromStore -> { 142 | if (maybeCallContextFromStore.isPresent()) { 143 | return completedFuture(maybeCallContextFromStore); 144 | } 145 | 146 | final String generatedCallContext = UUID.randomUUID().toString(); 147 | return store 148 | .put(prefixedResourceId, generatedCallContext) 149 | .thenApply( 150 | done -> { 151 | LOGGER.debug( 152 | "Successfully stored newly generated call context '{}' for resource id" 153 | + " '{}'", 154 | generatedCallContext, 155 | resourceId); 156 | return Optional.of(generatedCallContext); 157 | }); 158 | }); 159 | } 160 | 161 | private static Behavior handleReportHealth( 162 | final KeyValueStore store, final ReportHealth msg) { 163 | store.checkHealth().thenAccept(healthReport -> msg.replyTo().tell(healthReport)); 164 | 165 | return Behaviors.same(); 166 | } 167 | 168 | private static Behavior cleanup( 169 | final KeyValueStore store) { 170 | try { 171 | store.close(); 172 | } catch (Exception e) { 173 | LOGGER.warn("Unable to close store", e); 174 | } 175 | 176 | return Behaviors.same(); 177 | } 178 | 179 | private static String withKeyPrefix(final String resourceId) { 180 | return KEY_PREFIX + ":" + resourceId; 181 | } 182 | 183 | private static CompletableFuture failedFuture(final Throwable error) { 184 | CompletableFuture future = new CompletableFuture<>(); 185 | future.completeExceptionally(error); 186 | return future; 187 | } 188 | 189 | private static CompletionStage exceptionallyCompose( 190 | final CompletionStage stage, final Function> fn) { 191 | return stage 192 | .>thenApply(CompletableFuture::completedFuture) 193 | .exceptionally(fn) 194 | .thenCompose(Function.identity()); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/callcontext/api/CallContextLookupError.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.callcontext.api; 2 | 3 | public class CallContextLookupError extends Exception { 4 | 5 | private final String message; 6 | 7 | public CallContextLookupError(final String message) { 8 | this.message = message; 9 | } 10 | 11 | @Override 12 | public String getMessage() { 13 | return message; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/callcontext/api/CallContextProvided.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.callcontext.api; 2 | 3 | import org.apache.commons.lang3.builder.ReflectionToStringBuilder; 4 | import org.apache.commons.lang3.builder.ToStringStyle; 5 | 6 | public class CallContextProvided { 7 | 8 | private final String callContext; 9 | 10 | public CallContextProvided(final String callContext) { 11 | this.callContext = callContext; 12 | } 13 | 14 | public String callContext() { 15 | return callContext; 16 | } 17 | 18 | @Override 19 | public String toString() { 20 | return ReflectionToStringBuilder.toString(this, ToStringStyle.SHORT_PREFIX_STYLE); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/callcontext/api/CallContextProviderMessage.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.callcontext.api; 2 | 3 | public interface CallContextProviderMessage {} 4 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/callcontext/api/CallContextRegistered.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.callcontext.api; 2 | 3 | import static org.apache.commons.lang3.builder.EqualsBuilder.reflectionEquals; 4 | import static org.apache.commons.lang3.builder.HashCodeBuilder.reflectionHashCode; 5 | import static org.apache.commons.lang3.builder.ToStringBuilder.reflectionToString; 6 | import static org.apache.commons.lang3.builder.ToStringStyle.SHORT_PREFIX_STYLE; 7 | 8 | public class CallContextRegistered { 9 | 10 | private final String resourceId; 11 | private final String callContext; 12 | 13 | public CallContextRegistered(String resourceId, String callContext) { 14 | this.resourceId = resourceId; 15 | this.callContext = callContext; 16 | } 17 | 18 | public String resourceId() { 19 | return resourceId; 20 | } 21 | 22 | public String callContext() { 23 | return callContext; 24 | } 25 | 26 | @Override 27 | public String toString() { 28 | return reflectionToString(this, SHORT_PREFIX_STYLE); 29 | } 30 | 31 | @Override 32 | public boolean equals(final Object o) { 33 | return reflectionEquals(this, o); 34 | } 35 | 36 | @Override 37 | public int hashCode() { 38 | return reflectionHashCode(this); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/callcontext/api/ProvideCallContext.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.callcontext.api; 2 | 3 | import akka.actor.typed.ActorRef; 4 | import akka.pattern.StatusReply; 5 | import io.vavr.control.Option; 6 | import java.io.Serializable; 7 | import org.apache.commons.lang3.builder.ReflectionToStringBuilder; 8 | import org.apache.commons.lang3.builder.ToStringStyle; 9 | 10 | public class ProvideCallContext implements CallContextProviderMessage, Serializable { 11 | 12 | private final String resourceId; 13 | private final ProviderPolicy policy; 14 | private final Option maybeCallContextFromChannelVars; 15 | private final ActorRef> replyTo; 16 | 17 | @Deprecated 18 | public ProvideCallContext( 19 | final String resourceId, 20 | final Option maybeCallContextFromChannelVars, 21 | final ProviderPolicy policy) { 22 | this(resourceId, policy, maybeCallContextFromChannelVars, null); 23 | } 24 | 25 | public ProvideCallContext( 26 | final String resourceId, 27 | final ProviderPolicy policy, 28 | final Option maybeCallContextFromChannelVars, 29 | final ActorRef> replyTo) { 30 | this.resourceId = resourceId; 31 | this.policy = policy; 32 | this.maybeCallContextFromChannelVars = maybeCallContextFromChannelVars; 33 | this.replyTo = replyTo; 34 | } 35 | 36 | public String resourceId() { 37 | return resourceId; 38 | } 39 | 40 | public ProviderPolicy policy() { 41 | return policy; 42 | } 43 | 44 | public Option maybeCallContextFromChannelVars() { 45 | return maybeCallContextFromChannelVars; 46 | } 47 | 48 | public ActorRef> replyTo() { 49 | return replyTo; 50 | } 51 | 52 | @Override 53 | public String toString() { 54 | return ReflectionToStringBuilder.toString(this, ToStringStyle.SHORT_PREFIX_STYLE); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/callcontext/api/ProviderPolicy.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.callcontext.api; 2 | 3 | public enum ProviderPolicy { 4 | CREATE_IF_MISSING, 5 | LOOKUP_ONLY 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/callcontext/api/RegisterCallContext.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.callcontext.api; 2 | 3 | import java.io.Serializable; 4 | import org.apache.commons.lang3.builder.ReflectionToStringBuilder; 5 | import org.apache.commons.lang3.builder.ToStringStyle; 6 | 7 | public class RegisterCallContext implements CallContextProviderMessage, Serializable { 8 | 9 | private final String resourceId; 10 | private final String callContext; 11 | 12 | public RegisterCallContext(final String resourceId, final String callContext) { 13 | this.resourceId = resourceId; 14 | this.callContext = callContext; 15 | } 16 | 17 | public String resourceId() { 18 | return resourceId; 19 | } 20 | 21 | public String callContext() { 22 | return callContext; 23 | } 24 | 25 | @Override 26 | public String toString() { 27 | return ReflectionToStringBuilder.toString(this, ToStringStyle.SHORT_PREFIX_STYLE); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/callcontext/api/ReportHealth.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.callcontext.api; 2 | 3 | import static org.apache.commons.lang3.builder.ToStringStyle.SHORT_PREFIX_STYLE; 4 | 5 | import akka.actor.typed.ActorRef; 6 | import io.retel.ariproxy.health.api.HealthReport; 7 | import org.apache.commons.lang3.builder.ReflectionToStringBuilder; 8 | 9 | public class ReportHealth implements CallContextProviderMessage { 10 | final ActorRef replyTo; 11 | 12 | public ReportHealth(final ActorRef replyTo) { 13 | this.replyTo = replyTo; 14 | } 15 | 16 | public ActorRef replyTo() { 17 | return replyTo; 18 | } 19 | 20 | @Override 21 | public String toString() { 22 | return ReflectionToStringBuilder.toString(this, SHORT_PREFIX_STYLE); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/AriCommandResponseProcessing.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses; 2 | 3 | import akka.Done; 4 | import akka.actor.typed.ActorRef; 5 | import io.retel.ariproxy.boundary.callcontext.api.CallContextProviderMessage; 6 | import io.retel.ariproxy.boundary.callcontext.api.RegisterCallContext; 7 | import io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriCommand; 8 | import io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriResource; 9 | import io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriResourceRelation; 10 | import io.vavr.control.Option; 11 | import io.vavr.control.Try; 12 | 13 | public class AriCommandResponseProcessing { 14 | 15 | public static Try registerCallContext( 16 | final ActorRef callContextProvider, 17 | final String callContext, 18 | final AriCommand ariCommand) { 19 | 20 | if (!ariCommand.isCreationCommand()) { 21 | return Try.success(Done.done()); 22 | } 23 | 24 | final Option maybeResource = 25 | ariCommand 26 | .extractResourceRelations() 27 | .find(AriResourceRelation::isCreated) 28 | .map(AriResourceRelation::getResource); 29 | 30 | if (maybeResource.isEmpty()) { 31 | return Try.failure( 32 | new RuntimeException( 33 | String.format( 34 | "Failed to extract resourceId from command '%s'", ariCommand.toString()))); 35 | } 36 | 37 | final AriResource resource = maybeResource.get(); 38 | 39 | callContextProvider.tell(new RegisterCallContext(resource.getId(), callContext)); 40 | return Try.success(Done.done()); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/AriCommand.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import static org.apache.commons.lang3.builder.EqualsBuilder.reflectionEquals; 4 | import static org.apache.commons.lang3.builder.HashCodeBuilder.reflectionHashCode; 5 | import static org.apache.commons.lang3.builder.ToStringBuilder.reflectionToString; 6 | import static org.apache.commons.lang3.builder.ToStringStyle.SHORT_PREFIX_STYLE; 7 | 8 | import com.fasterxml.jackson.annotation.JsonCreator; 9 | import com.fasterxml.jackson.annotation.JsonProperty; 10 | import com.fasterxml.jackson.core.JsonProcessingException; 11 | import com.fasterxml.jackson.databind.JsonNode; 12 | import com.fasterxml.jackson.databind.ObjectMapper; 13 | import io.vavr.Value; 14 | import io.vavr.collection.List; 15 | import io.vavr.control.Option; 16 | 17 | public class AriCommand { 18 | private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); 19 | 20 | private final String method; 21 | private final String url; 22 | private final JsonNode body; 23 | 24 | @JsonCreator 25 | public AriCommand( 26 | @JsonProperty("method") final String method, 27 | @JsonProperty("url") final String url, 28 | @JsonProperty("body") final JsonNode body) { 29 | this.method = method; 30 | this.url = url; 31 | this.body = body; 32 | } 33 | 34 | public String getMethod() { 35 | return method; 36 | } 37 | 38 | public String getUrl() { 39 | return url; 40 | } 41 | 42 | public JsonNode getBody() { 43 | return body; 44 | } 45 | 46 | public AriCommandType extractCommandType() { 47 | final String uri = getUrl().split("\\?")[0]; 48 | return AriCommandType.fromRequestUri(uri); 49 | } 50 | 51 | public List extractResourceRelations() { 52 | 53 | final String uri = getUrl().split("\\?")[0]; 54 | List ariResources = AriCommandType.extractAllResources(uri); 55 | 56 | final AriCommandType commandType = AriCommandType.fromRequestUri(uri); 57 | 58 | if (!commandType.isRouteForResourceCreation()) { 59 | return ariResources.map(r -> new AriResourceRelation(r, false)); 60 | } 61 | 62 | final Option createdResourceFromUri = 63 | ariResources.find(resource -> resource.getType().equals(commandType.getResourceType())); 64 | 65 | try { 66 | final Option createdResourceFromBody = 67 | commandType 68 | .extractResourceIdFromBody(OBJECT_MAPPER.writeValueAsString(getBody())) 69 | .flatMap(Value::toOption) 70 | .map(resourceId -> new AriResource(commandType.getResourceType(), resourceId)); 71 | 72 | final Option createdResource = 73 | createdResourceFromUri.orElse(createdResourceFromBody); 74 | 75 | if (createdResource.isDefined() && !ariResources.contains(createdResource.get())) { 76 | ariResources = ariResources.push(createdResource.get()); 77 | } 78 | 79 | return ariResources.map( 80 | ariResource -> 81 | new AriResourceRelation( 82 | ariResource, createdResource.map(r -> r.equals(ariResource)).getOrElse(false))); 83 | } catch (JsonProcessingException e) { 84 | throw new IllegalStateException("Unable to serialize command body: " + this, e); 85 | } 86 | } 87 | 88 | public boolean isCreationCommand() { 89 | return extractCommandType().isRouteForResourceCreation() && "POST".equals(method); 90 | } 91 | 92 | @Override 93 | public String toString() { 94 | return reflectionToString(this, SHORT_PREFIX_STYLE); 95 | } 96 | 97 | @Override 98 | public boolean equals(final Object o) { 99 | return reflectionEquals(this, o); 100 | } 101 | 102 | @Override 103 | public int hashCode() { 104 | return reflectionHashCode(this); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/AriCommandEnvelope.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import org.apache.commons.lang3.builder.ReflectionToStringBuilder; 4 | import org.apache.commons.lang3.builder.ToStringStyle; 5 | 6 | public class AriCommandEnvelope { 7 | 8 | private AriCommand ariCommand; 9 | private String callContext; 10 | private String commandId; 11 | 12 | private AriCommandEnvelope() {} 13 | 14 | public AriCommandEnvelope(AriCommand ariCommand, String callContext, String commandId) { 15 | this.ariCommand = ariCommand; 16 | this.callContext = callContext; 17 | this.commandId = commandId; 18 | } 19 | 20 | public AriCommand getAriCommand() { 21 | return ariCommand; 22 | } 23 | 24 | public String getCallContext() { 25 | return callContext; 26 | } 27 | 28 | public String getCommandId() { 29 | return commandId; 30 | } 31 | 32 | @Override 33 | public String toString() { 34 | return ReflectionToStringBuilder.toString(this, ToStringStyle.SHORT_PREFIX_STYLE); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/AriMessageEnvelope.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import static org.apache.commons.lang3.builder.EqualsBuilder.reflectionEquals; 4 | import static org.apache.commons.lang3.builder.HashCodeBuilder.reflectionHashCode; 5 | import static org.apache.commons.lang3.builder.ToStringBuilder.reflectionToString; 6 | import static org.apache.commons.lang3.builder.ToStringStyle.SHORT_PREFIX_STYLE; 7 | 8 | import java.util.List; 9 | 10 | public class AriMessageEnvelope { 11 | private final AriMessageType type; 12 | private final String commandsTopic; 13 | private final Object payload; 14 | private final String callContext; 15 | private final List resources; 16 | private final String commandId; 17 | private final CommandRequest commandRequest; 18 | 19 | public AriMessageEnvelope( 20 | final AriMessageType type, 21 | final String commandsTopic, 22 | final Object payload, 23 | final String callContext, 24 | final List resources, 25 | final String commandId, 26 | final CommandRequest commandRequest) { 27 | this.commandsTopic = commandsTopic; 28 | this.payload = payload; 29 | this.callContext = callContext; 30 | this.type = type; 31 | this.resources = resources; 32 | this.commandId = commandId; 33 | this.commandRequest = commandRequest; 34 | } 35 | 36 | public AriMessageEnvelope( 37 | final AriMessageType type, 38 | final String commandsTopic, 39 | final Object payload, 40 | final String callContext, 41 | final String commandId, 42 | final CommandRequest commandRequest) { 43 | this(type, commandsTopic, payload, callContext, null, commandId, commandRequest); 44 | } 45 | 46 | public AriMessageEnvelope( 47 | final AriMessageType type, 48 | final String commandsTopic, 49 | final Object payload, 50 | final String callContext, 51 | final List resources) { 52 | this(type, commandsTopic, payload, callContext, resources, null, null); 53 | } 54 | 55 | public AriMessageEnvelope( 56 | final AriMessageType type, 57 | final String commandsTopic, 58 | final Object payload, 59 | final String callContext) { 60 | this(type, commandsTopic, payload, callContext, null, null, null); 61 | } 62 | 63 | public AriMessageType getType() { 64 | return type; 65 | } 66 | 67 | public String getCommandsTopic() { 68 | return commandsTopic; 69 | } 70 | 71 | public Object getPayload() { 72 | return payload; 73 | } 74 | 75 | public String getCallContext() { 76 | return callContext; 77 | } 78 | 79 | public List getResources() { 80 | return resources; 81 | } 82 | 83 | public String getCommandId() { 84 | return commandId; 85 | } 86 | 87 | public CommandRequest getCommandRequest() { 88 | return commandRequest; 89 | } 90 | 91 | @Override 92 | public String toString() { 93 | return reflectionToString(this, SHORT_PREFIX_STYLE); 94 | } 95 | 96 | @Override 97 | public boolean equals(final Object o) { 98 | return reflectionEquals(this, o); 99 | } 100 | 101 | @Override 102 | public int hashCode() { 103 | return reflectionHashCode(this); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/AriMessageResource.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import static org.apache.commons.lang3.builder.EqualsBuilder.reflectionEquals; 4 | import static org.apache.commons.lang3.builder.HashCodeBuilder.reflectionHashCode; 5 | import static org.apache.commons.lang3.builder.ToStringBuilder.reflectionToString; 6 | import static org.apache.commons.lang3.builder.ToStringStyle.SHORT_PREFIX_STYLE; 7 | 8 | public class AriMessageResource { 9 | final ResourceType type; 10 | final String id; 11 | 12 | public AriMessageResource(final ResourceType type, final String id) { 13 | this.type = type; 14 | this.id = id; 15 | } 16 | 17 | public ResourceType getType() { 18 | return type; 19 | } 20 | 21 | public String getId() { 22 | return id; 23 | } 24 | 25 | @Override 26 | public String toString() { 27 | return reflectionToString(this, SHORT_PREFIX_STYLE); 28 | } 29 | 30 | @Override 31 | public boolean equals(final Object o) { 32 | return reflectionEquals(this, o); 33 | } 34 | 35 | @Override 36 | public int hashCode() { 37 | return reflectionHashCode(this); 38 | } 39 | 40 | enum ResourceType { 41 | CHANNEL, 42 | BRIDGE, 43 | PLAYBACK, 44 | RECORDING, 45 | SNOOPING 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/AriMessageType.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import static io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriResourceType.*; 4 | import static io.vavr.API.None; 5 | import static io.vavr.API.Set; 6 | import static io.vavr.API.Some; 7 | 8 | import com.fasterxml.jackson.databind.JsonNode; 9 | import io.vavr.control.Option; 10 | import io.vavr.control.Try; 11 | import java.util.function.Function; 12 | import org.apache.commons.lang3.StringUtils; 13 | 14 | // Supported event types 15 | public enum AriMessageType { 16 | APPLICATION_REPLACED("ApplicationReplaced", null, body -> None()), 17 | BRIDGE_BLIND_TRANSFER("BridgeBlindTransfer", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 18 | BRIDGE_CREATED("BridgeCreated", BRIDGE, resourceIdFromBody(XPaths.BRIDGE_ID)), 19 | BRIDGE_DESTROYED("BridgeDestroyed", BRIDGE, resourceIdFromBody(XPaths.BRIDGE_ID)), 20 | BRIDGE_MERGED("BridgeMerged", BRIDGE, resourceIdFromBody(XPaths.BRIDGE_ID)), 21 | BRIDGE_VIDEOSOURCE_CHANGED( 22 | "BridgeVideoSourceChanged", BRIDGE, resourceIdFromBody(XPaths.BRIDGE_ID)), 23 | CHANNEL_CALLERID("ChannelCallerId", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 24 | CHANNEL_CONNECTED_LINE("ChannelConnectedLine", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 25 | CHANNEL_CREATED("ChannelCreated", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 26 | CHANNEL_DESTROYED("ChannelDestroyed", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 27 | CHANNEL_DIALPLAN("ChannelDialplan", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 28 | CHANNEL_DTMF_RECEIVED("ChannelDtmfReceived", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 29 | CHANNEL_ENTERED_BRIDGE("ChannelEnteredBridge", BRIDGE, resourceIdFromBody(XPaths.BRIDGE_ID)), 30 | CHANNEL_HANGUP_REQUEST("ChannelHangupRequest", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 31 | CHANNEL_HOLD("ChannelHold", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 32 | CHANNEL_LEFT_BRIDGE("ChannelLeftBridge", BRIDGE, resourceIdFromBody(XPaths.BRIDGE_ID)), 33 | CHANNEL_STATE_CHANGE("ChannelStateChange", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 34 | CHANNEL_TALKING_FINISHED( 35 | "ChannelTalkingFinished", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 36 | CHANNEL_TALKING_STARTED("ChannelTalkingStarted", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 37 | CHANNEL_UNHOLD("ChannelUnhold", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 38 | DIAL("Dial", null, resourceIdFromBody(XPaths.PEER_ID)), 39 | PLAYBACK_CONTINUING("PlaybackContinuing", PLAYBACK, resourceIdFromBody(XPaths.PLAYBACK_ID)), 40 | PLAYBACK_FINISHED("PlaybackFinished", PLAYBACK, resourceIdFromBody(XPaths.PLAYBACK_ID)), 41 | PLAYBACK_STARTED("PlaybackStarted", PLAYBACK, resourceIdFromBody(XPaths.PLAYBACK_ID)), 42 | RECORDING_FAILED("RecordingFailed", RECORDING, resourceIdFromBody(XPaths.RECORDING_NAME)), 43 | RECORDING_FINISHED("RecordingFinished", RECORDING, resourceIdFromBody(XPaths.RECORDING_NAME)), 44 | RECORDING_STARTED("RecordingStarted", RECORDING, resourceIdFromBody(XPaths.RECORDING_NAME)), 45 | STASIS_END("StasisEnd", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 46 | STASIS_START("StasisStart", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 47 | RESPONSE("AriResponse", null, body -> None()), 48 | CHANNELVARSET("ChannelVarset", CHANNEL, resourceIdFromBody(XPaths.CHANNEL_ID)), 49 | UNKNOWN( 50 | "UnknownAriMessage", 51 | null, 52 | body -> 53 | Some( 54 | Try.failure( 55 | new RuntimeException( 56 | String.format("Failed to extract resourceId from body=%s", body))))); 57 | 58 | private final String typeName; 59 | private final AriResourceType resourceType; 60 | private final Function>> resourceIdExtractor; 61 | 62 | AriMessageType( 63 | final String typeName, 64 | final AriResourceType resourceType, 65 | final Function>> resourceIdExtractor) { 66 | this.typeName = typeName; 67 | this.resourceType = resourceType; 68 | this.resourceIdExtractor = resourceIdExtractor; 69 | } 70 | 71 | public static AriMessageType fromType(final String candidateType) { 72 | return Set(AriMessageType.values()) 73 | .find(ariMessageType -> ariMessageType.typeName.equals(candidateType)) 74 | .getOrElse(UNKNOWN); 75 | } 76 | 77 | public Option getResourceType() { 78 | return Option.of(resourceType); 79 | } 80 | 81 | public Option> extractResourceIdFromBody(final JsonNode body) { 82 | return resourceIdExtractor.apply(body); 83 | } 84 | 85 | private static Function>> resourceIdFromBody( 86 | final String resourceIdXPath) { 87 | return body -> 88 | Some( 89 | Option.of(body.at(resourceIdXPath)) 90 | .map(JsonNode::asText) 91 | .flatMap(type -> StringUtils.isBlank(type) ? None() : Some(type)) 92 | .toTry( 93 | () -> 94 | new Throwable( 95 | String.format( 96 | "Failed to extract resourceId at path=%s from body=%s", 97 | resourceIdXPath, body)))); 98 | } 99 | 100 | private static class XPaths { 101 | static final String BRIDGE_ID = "/bridge/id"; 102 | static final String CHANNEL_ID = "/channel/id"; 103 | static final String PLAYBACK_ID = "/playback/id"; 104 | static final String RECORDING_NAME = "/recording/name"; 105 | static final String PEER_ID = "/peer/id"; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/AriResource.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import static org.apache.commons.lang3.builder.EqualsBuilder.reflectionEquals; 4 | import static org.apache.commons.lang3.builder.HashCodeBuilder.reflectionHashCode; 5 | import static org.apache.commons.lang3.builder.ToStringBuilder.reflectionToString; 6 | import static org.apache.commons.lang3.builder.ToStringStyle.SHORT_PREFIX_STYLE; 7 | 8 | public class AriResource { 9 | private final AriResourceType type; 10 | private final String id; 11 | 12 | public AriResource(final AriResourceType type, final String id) { 13 | this.type = type; 14 | this.id = id; 15 | } 16 | 17 | public AriResourceType getType() { 18 | return type; 19 | } 20 | 21 | public String getId() { 22 | return id; 23 | } 24 | 25 | @Override 26 | public String toString() { 27 | return reflectionToString(this, SHORT_PREFIX_STYLE); 28 | } 29 | 30 | @Override 31 | public boolean equals(final Object o) { 32 | return reflectionEquals(this, o); 33 | } 34 | 35 | @Override 36 | public int hashCode() { 37 | return reflectionHashCode(this); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/AriResourceRelation.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import static org.apache.commons.lang3.builder.EqualsBuilder.reflectionEquals; 4 | import static org.apache.commons.lang3.builder.HashCodeBuilder.reflectionHashCode; 5 | import static org.apache.commons.lang3.builder.ToStringBuilder.reflectionToString; 6 | import static org.apache.commons.lang3.builder.ToStringStyle.SHORT_PREFIX_STYLE; 7 | 8 | public class AriResourceRelation { 9 | private final AriResource resource; 10 | private final boolean isCreated; 11 | 12 | public AriResourceRelation(final AriResource resource, final boolean isCreated) { 13 | this.resource = resource; 14 | this.isCreated = isCreated; 15 | } 16 | 17 | public AriResource getResource() { 18 | return resource; 19 | } 20 | 21 | public boolean isCreated() { 22 | return isCreated; 23 | } 24 | 25 | @Override 26 | public String toString() { 27 | return reflectionToString(this, SHORT_PREFIX_STYLE); 28 | } 29 | 30 | @Override 31 | public boolean equals(final Object o) { 32 | return reflectionEquals(this, o); 33 | } 34 | 35 | @Override 36 | public int hashCode() { 37 | return reflectionHashCode(this); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/AriResourceType.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import java.util.Arrays; 4 | import java.util.Optional; 5 | 6 | public enum AriResourceType { 7 | BRIDGE("{bridgeId}"), 8 | CHANNEL("{channelId}"), 9 | PLAYBACK("{playbackId}"), 10 | RECORDING("{recordingName}"), 11 | SNOOPING("{snoopId}"), 12 | UNKNOWN(null); 13 | 14 | private final String pathResourceIdPlaceholder; 15 | 16 | AriResourceType(final String pathResourceIdPlaceholder) { 17 | this.pathResourceIdPlaceholder = pathResourceIdPlaceholder; 18 | } 19 | 20 | public static Optional of(final String pathResourceIdentifierPlaceholder) { 21 | return Arrays.stream(values()) 22 | .filter( 23 | item -> 24 | item.getPathResourceIdentifierPlaceholder() 25 | .equals(pathResourceIdentifierPlaceholder)) 26 | .findFirst(); 27 | } 28 | 29 | public String getPathResourceIdentifierPlaceholder() { 30 | return pathResourceIdPlaceholder; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/AriResponse.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import static org.apache.commons.lang3.builder.ToStringBuilder.reflectionToString; 4 | import static org.apache.commons.lang3.builder.ToStringStyle.SHORT_PREFIX_STYLE; 5 | 6 | import com.fasterxml.jackson.annotation.JsonProperty; 7 | import com.fasterxml.jackson.databind.JsonNode; 8 | 9 | public class AriResponse { 10 | 11 | @JsonProperty(value = "status_code") 12 | private int statusCode; 13 | 14 | private JsonNode body; 15 | 16 | public AriResponse() {} 17 | 18 | public AriResponse(int statusCode, JsonNode body) { 19 | this.statusCode = statusCode; 20 | this.body = body; 21 | } 22 | 23 | public int getStatusCode() { 24 | return statusCode; 25 | } 26 | 27 | public JsonNode getBody() { 28 | return body; 29 | } 30 | 31 | @Override 32 | public String toString() { 33 | return reflectionToString(this, SHORT_PREFIX_STYLE); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/CallContextAndCommandRequestContext.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import org.apache.commons.lang3.builder.ReflectionToStringBuilder; 4 | import org.apache.commons.lang3.builder.ToStringStyle; 5 | 6 | public class CallContextAndCommandRequestContext { 7 | 8 | private final String callContext; 9 | private final String commandId; 10 | private final AriCommand ariCommand; 11 | 12 | public CallContextAndCommandRequestContext( 13 | String callContext, String commandId, AriCommand ariCommand) { 14 | this.callContext = callContext; 15 | this.commandId = commandId; 16 | this.ariCommand = ariCommand; 17 | } 18 | 19 | public String getCallContext() { 20 | return callContext; 21 | } 22 | 23 | public String getCommandId() { 24 | return commandId; 25 | } 26 | 27 | public AriCommand getAriCommand() { 28 | return ariCommand; 29 | } 30 | 31 | @Override 32 | public String toString() { 33 | return ReflectionToStringBuilder.toString(this, ToStringStyle.SHORT_PREFIX_STYLE); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/CallContextAndResourceId.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import org.apache.commons.lang3.builder.ReflectionToStringBuilder; 4 | import org.apache.commons.lang3.builder.ToStringStyle; 5 | 6 | public class CallContextAndResourceId { 7 | 8 | private final String callContext; 9 | private final String resourceId; 10 | private final String commandId; 11 | 12 | public CallContextAndResourceId(String callContext, String resourceId, String commandId) { 13 | this.callContext = callContext; 14 | this.resourceId = resourceId; 15 | this.commandId = commandId; 16 | } 17 | 18 | public String getCallContext() { 19 | return callContext; 20 | } 21 | 22 | public String getResourceId() { 23 | return resourceId; 24 | } 25 | 26 | public String getCommandId() { 27 | return commandId; 28 | } 29 | 30 | @Override 31 | public String toString() { 32 | return ReflectionToStringBuilder.toString(this, ToStringStyle.SHORT_PREFIX_STYLE); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/CommandRequest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import static org.apache.commons.lang3.builder.ToStringBuilder.reflectionToString; 4 | import static org.apache.commons.lang3.builder.ToStringStyle.SHORT_PREFIX_STYLE; 5 | 6 | public class CommandRequest { 7 | private final String method; 8 | private final String url; 9 | 10 | public CommandRequest(String method, String url) { 11 | this.method = method; 12 | this.url = url; 13 | } 14 | 15 | public String getMethod() { 16 | return method; 17 | } 18 | 19 | public String getUrl() { 20 | return url; 21 | } 22 | 23 | @Override 24 | public String toString() { 25 | return reflectionToString(this, SHORT_PREFIX_STYLE); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/CommandResponseHandler.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import akka.http.javadsl.model.HttpRequest; 4 | import akka.http.javadsl.model.HttpResponse; 5 | import io.vavr.Tuple2; 6 | import java.util.concurrent.CompletionStage; 7 | 8 | @FunctionalInterface 9 | public interface CommandResponseHandler { 10 | CompletionStage apply( 11 | Tuple2 placeholder); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/ExtractorNotAvailable.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | public class ExtractorNotAvailable extends Throwable { 4 | 5 | private final String bodyOrUri; 6 | 7 | public ExtractorNotAvailable(final String bodyOrUri) { 8 | this.bodyOrUri = bodyOrUri; 9 | } 10 | 11 | @Override 12 | public String getMessage() { 13 | return String.format("No extractor defined for body/uri=%s", bodyOrUri); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/events/AriEventProcessing.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.events; 2 | 3 | import akka.NotUsed; 4 | import akka.actor.typed.ActorRef; 5 | import akka.actor.typed.ActorSystem; 6 | import akka.actor.typed.javadsl.AskPattern; 7 | import akka.http.javadsl.model.ws.Message; 8 | import akka.stream.javadsl.Source; 9 | import com.fasterxml.jackson.databind.JsonNode; 10 | import com.fasterxml.jackson.databind.ObjectMapper; 11 | import com.fasterxml.jackson.databind.ObjectReader; 12 | import com.fasterxml.jackson.databind.ObjectWriter; 13 | import io.retel.ariproxy.boundary.callcontext.api.*; 14 | import io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriMessageEnvelope; 15 | import io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriMessageType; 16 | import io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriResource; 17 | import io.vavr.control.Option; 18 | import io.vavr.control.Try; 19 | import java.time.Duration; 20 | import java.util.function.Function; 21 | import org.apache.commons.lang3.StringUtils; 22 | import org.apache.kafka.clients.producer.ProducerRecord; 23 | import org.slf4j.Logger; 24 | 25 | public class AriEventProcessing { 26 | 27 | private static final ObjectMapper mapper = new ObjectMapper(); 28 | private static final ObjectReader reader = mapper.reader(); 29 | private static final ObjectWriter writer = mapper.writerFor(AriMessageEnvelope.class); 30 | // Note: This timeout is pretty high right now as the initial redis interaction takes quite some 31 | // time... 32 | private static final Duration PROVIDE_CALLCONTEXT_TIMEOUT = Duration.ofMillis(1000); 33 | 34 | public static Source, NotUsed> generateProducerRecordFromEvent( 35 | final String kafkaCommandsTopic, 36 | final String kafkaEventsAndResponsesTopic, 37 | final Message message, 38 | final ActorRef callContextProvider, 39 | final Logger log, 40 | final Runnable applicationReplacedHandler, 41 | final ActorSystem system) { 42 | 43 | final JsonNode messageBody = 44 | Try.of(() -> reader.readTree(message.asTextMessage().getStrictText())) 45 | .getOrElseThrow(t -> new RuntimeException(t)); 46 | 47 | final String eventTypeString = messageBody.get("type").asText(); 48 | final AriMessageType ariMessageType = AriMessageType.fromType(eventTypeString); 49 | 50 | if (AriMessageType.APPLICATION_REPLACED.equals(ariMessageType)) { 51 | log.info("Got APPLICATION_REPLACED event, shutting down..."); 52 | applicationReplacedHandler.run(); 53 | return Source.empty(); 54 | } 55 | 56 | final Option maybeCallContextFromChannelVars = 57 | Option.of(messageBody.at("/channel/channelvars/CALL_CONTEXT").asText()) 58 | .filter(StringUtils::isNotBlank); 59 | 60 | return ariMessageType 61 | .extractResourceIdFromBody(messageBody) 62 | .map( 63 | resourceIdTry -> 64 | resourceIdTry.flatMap( 65 | resourceId -> { 66 | final ProviderPolicy providerPolicy = 67 | AriMessageType.STASIS_START.equals(ariMessageType) 68 | ? ProviderPolicy.CREATE_IF_MISSING 69 | : ProviderPolicy.LOOKUP_ONLY; 70 | final Try maybeCallContext = 71 | getCallContext( 72 | resourceId, 73 | callContextProvider, 74 | maybeCallContextFromChannelVars, 75 | providerPolicy, 76 | system); 77 | return maybeCallContext.flatMap( 78 | callContext -> 79 | createProducerRecord( 80 | kafkaCommandsTopic, 81 | kafkaEventsAndResponsesTopic, 82 | ariMessageType, 83 | resourceId, 84 | log, 85 | callContext, 86 | messageBody) 87 | .map(Source::single)); 88 | })) 89 | .toTry() 90 | .flatMap(Function.identity()) 91 | .getOrElseThrow(t -> new RuntimeException(t)); 92 | } 93 | 94 | private static Try> createProducerRecord( 95 | final String kafkaCommandsTopic, 96 | final String kafkaEventsAndResponsesTopic, 97 | final AriMessageType messageType, 98 | final String resourceId, 99 | final Logger log, 100 | final String callContext, 101 | final JsonNode messageBody) { 102 | 103 | final java.util.List resources = 104 | messageType 105 | .getResourceType() 106 | .map(resourceType -> new AriResource(resourceType, resourceId)) 107 | .toJavaList(); 108 | final AriMessageEnvelope envelope = 109 | new AriMessageEnvelope( 110 | messageType, kafkaCommandsTopic, messageBody, callContext, resources); 111 | 112 | return Try.of(() -> writer.writeValueAsString(envelope)) 113 | .map( 114 | marshalledEnvelope -> { 115 | log.debug("[ARI MESSAGE TYPE] {}", envelope.getType()); 116 | return new ProducerRecord<>( 117 | kafkaEventsAndResponsesTopic, callContext, marshalledEnvelope); 118 | }); 119 | } 120 | 121 | public static Try getCallContext( 122 | final String resourceId, 123 | final ActorRef callContextProvider, 124 | final Option maybeCallContextFromChannelVars, 125 | final ProviderPolicy providerPolicy, 126 | final ActorSystem system) { 127 | 128 | return Try.of( 129 | () -> { 130 | final CallContextProvided response = 131 | AskPattern.askWithStatus( 132 | callContextProvider, 133 | replyTo -> 134 | new ProvideCallContext( 135 | resourceId, providerPolicy, maybeCallContextFromChannelVars, replyTo), 136 | PROVIDE_CALLCONTEXT_TIMEOUT, 137 | system.scheduler()) 138 | .toCompletableFuture() 139 | .get(); 140 | 141 | return response.callContext(); 142 | }); 143 | } 144 | 145 | public static Option getValueFromMessageByPath(Message message, String path) { 146 | return Try.of(() -> reader.readTree(message.asTextMessage().getStrictText())) 147 | .map(root -> root.at(path)) 148 | .map(JsonNode::asText) 149 | .filter(StringUtils::isNotBlank) 150 | .toOption(); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/boundary/events/WebsocketMessageToProducerRecordTranslator.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.events; 2 | 3 | import static io.retel.ariproxy.boundary.events.AriEventProcessing.*; 4 | import static io.retel.ariproxy.boundary.events.AriEventProcessing.getValueFromMessageByPath; 5 | 6 | import akka.NotUsed; 7 | import akka.actor.typed.ActorRef; 8 | import akka.actor.typed.ActorSystem; 9 | import akka.event.Logging; 10 | import akka.http.javadsl.model.ws.Message; 11 | import akka.japi.function.Function; 12 | import akka.stream.ActorAttributes; 13 | import akka.stream.Attributes; 14 | import akka.stream.Materializer; 15 | import akka.stream.Supervision; 16 | import akka.stream.javadsl.RunnableGraph; 17 | import akka.stream.javadsl.Sink; 18 | import akka.stream.javadsl.Source; 19 | import com.typesafe.config.Config; 20 | import com.typesafe.config.ConfigFactory; 21 | import io.retel.ariproxy.boundary.callcontext.api.CallContextProviderMessage; 22 | import io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriMessageType; 23 | import io.retel.ariproxy.metrics.Metrics; 24 | import org.apache.kafka.clients.producer.ProducerRecord; 25 | 26 | public class WebsocketMessageToProducerRecordTranslator { 27 | 28 | private static final String SERVICE = "service"; 29 | private static final String KAFKA = "kafka"; 30 | private static final String EVENTS_AND_RESPONSES_TOPIC = "events-and-responses-topic"; 31 | private static final String COMMANDS_TOPIC = "commands-topic"; 32 | 33 | private static final Attributes LOG_LEVELS = 34 | Attributes.createLogLevels(Logging.InfoLevel(), Logging.InfoLevel(), Logging.ErrorLevel()); 35 | 36 | public static RunnableGraph eventProcessing( 37 | final ActorSystem system, 38 | final ActorRef callContextProvider, 39 | final Source source, 40 | final Sink, NotUsed> sink, 41 | final Runnable applicationReplacedHandler) { 42 | final Function decider = 43 | t -> { 44 | system.log().error("WebsocketMessageToProducerRecordTranslator stream failed", t); 45 | Metrics.countEventProcessorRestart(); 46 | return (Supervision.Directive) Supervision.resume(); 47 | }; 48 | 49 | final Config kafkaConfig = ConfigFactory.load().getConfig(SERVICE).getConfig(KAFKA); 50 | final String commandsTopic = kafkaConfig.getString(COMMANDS_TOPIC); 51 | final String eventsAndResponsesTopic = kafkaConfig.getString(EVENTS_AND_RESPONSES_TOPIC); 52 | 53 | return source 54 | .map( 55 | msg -> 56 | msg.asTextMessage() 57 | .toStrict(200, Materializer.createMaterializer(system)) 58 | .toCompletableFuture() 59 | .join()) 60 | .wireTap(msg -> gatherMetrics(msg)) 61 | .flatMapConcat( 62 | (msg) -> 63 | generateProducerRecordFromEvent( 64 | commandsTopic, 65 | eventsAndResponsesTopic, 66 | msg, 67 | callContextProvider, 68 | system.log(), 69 | applicationReplacedHandler, 70 | system)) 71 | .log(">>> ARI EVENT", ProducerRecord::value) 72 | .withAttributes(LOG_LEVELS) 73 | .to(sink) 74 | .withAttributes(ActorAttributes.withSupervisionStrategy(decider)); 75 | } 76 | 77 | private static void gatherMetrics(final Message message) { 78 | final AriMessageType type = 79 | getValueFromMessageByPath(message, "/type") 80 | .map(AriMessageType::fromType) 81 | .getOrElse(AriMessageType.UNKNOWN); 82 | 83 | Metrics.countAriEvent(type); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/health/AriConnectionCheck.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.health; 2 | 3 | import static org.apache.commons.lang3.builder.ToStringStyle.SHORT_PREFIX_STYLE; 4 | 5 | import akka.actor.typed.ActorRef; 6 | import akka.actor.typed.Behavior; 7 | import akka.actor.typed.javadsl.ActorContext; 8 | import akka.actor.typed.javadsl.Behaviors; 9 | import akka.http.javadsl.Http; 10 | import akka.http.javadsl.model.*; 11 | import akka.http.javadsl.model.headers.HttpCredentials; 12 | import com.typesafe.config.Config; 13 | import io.retel.ariproxy.health.api.HealthReport; 14 | import java.util.concurrent.CompletableFuture; 15 | import java.util.concurrent.CompletionStage; 16 | import org.apache.commons.lang3.builder.ReflectionToStringBuilder; 17 | 18 | public class AriConnectionCheck { 19 | 20 | public static final String URI = "uri"; 21 | public static final String PASSWORD = "password"; 22 | public static final String USER = "user"; 23 | public static final String ASTERISK_PING_ROUTE = "/asterisk/ping"; 24 | 25 | private AriConnectionCheck() { 26 | throw new IllegalStateException("Utility class"); 27 | } 28 | 29 | public static Behavior create(final Config restConfig) { 30 | 31 | return Behaviors.setup( 32 | context -> 33 | Behaviors.receive(ReportAriConnectionHealth.class) 34 | .onMessage( 35 | ReportAriConnectionHealth.class, 36 | message -> reportHealth(context, restConfig, message)) 37 | .build()); 38 | } 39 | 40 | private static Behavior reportHealth( 41 | final ActorContext context, 42 | final Config restConfig, 43 | final ReportAriConnectionHealth message) { 44 | provideHealthReport(context, restConfig) 45 | .thenAccept(healthReport -> message.replyTo().tell(healthReport)); 46 | 47 | return Behaviors.same(); 48 | } 49 | 50 | private static CompletableFuture provideHealthReport( 51 | final ActorContext context, final Config restConfig) { 52 | 53 | final String restUri = restConfig.getString(URI); 54 | final String restUser = restConfig.getString(USER); 55 | final String restPassword = restConfig.getString(PASSWORD); 56 | 57 | final HttpRequest httpRequest = 58 | HttpRequest.create() 59 | .withMethod(HttpMethods.GET) 60 | .addCredentials(HttpCredentials.createBasicHttpCredentials(restUser, restPassword)) 61 | .withUri(restUri + ASTERISK_PING_ROUTE); 62 | 63 | final CompletionStage responseCompletionStage = 64 | Http.get(context.getSystem()).singleRequest(httpRequest); 65 | return responseCompletionStage 66 | .handle( 67 | (httpResponse, error) -> { 68 | if (error != null) { 69 | return HealthReport.error( 70 | AriConnectionCheck.class, 71 | "error during connection to ARI: %s".formatted(error.getMessage())); 72 | } 73 | 74 | if (httpResponse.status().equals(StatusCodes.OK)) { 75 | return HealthReport.ok(); 76 | 77 | } else { 78 | return HealthReport.error( 79 | AriConnectionCheck.class, 80 | "unexpected return value: %s".formatted(httpResponse.status())); 81 | } 82 | }) 83 | .toCompletableFuture(); 84 | } 85 | 86 | public record ReportAriConnectionHealth(ActorRef replyTo) { 87 | 88 | @Override 89 | public String toString() { 90 | return ReflectionToStringBuilder.toString(this, SHORT_PREFIX_STYLE); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/health/HealthService.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.health; 2 | 3 | import static akka.http.javadsl.server.Directives.*; 4 | import static akka.http.javadsl.server.Directives.pathPrefix; 5 | 6 | import akka.actor.typed.ActorSystem; 7 | import akka.http.javadsl.Http; 8 | import akka.http.javadsl.ServerBinding; 9 | import akka.http.javadsl.model.ContentTypes; 10 | import akka.http.javadsl.model.HttpEntities; 11 | import akka.http.javadsl.model.HttpResponse; 12 | import akka.http.javadsl.model.StatusCodes; 13 | import akka.http.javadsl.server.Route; 14 | import com.fasterxml.jackson.core.JsonProcessingException; 15 | import com.fasterxml.jackson.databind.ObjectMapper; 16 | import com.fasterxml.jackson.databind.ObjectWriter; 17 | import io.retel.ariproxy.boundary.callcontext.CallContextProvider; 18 | import io.retel.ariproxy.health.api.HealthReport; 19 | import io.retel.ariproxy.health.api.HealthResponse; 20 | import java.util.Collection; 21 | import java.util.concurrent.CompletableFuture; 22 | import java.util.concurrent.ExecutionException; 23 | import java.util.function.Supplier; 24 | import org.slf4j.Logger; 25 | import org.slf4j.LoggerFactory; 26 | 27 | public class HealthService { 28 | 29 | private static final Logger LOGGER = LoggerFactory.getLogger(CallContextProvider.class); 30 | private static final ObjectWriter writer = new ObjectMapper().writer(); 31 | 32 | private HealthService() { 33 | throw new IllegalStateException("Utility class"); 34 | } 35 | 36 | public static ServerBinding run( 37 | final ActorSystem system, 38 | final Collection>> healthSuppliers, 39 | final Supplier metricsSupplier, 40 | final int httpPort) { 41 | try { 42 | final String address = "0.0.0.0"; 43 | final ServerBinding binding = 44 | Http.get(system) 45 | .newServerAt(address, httpPort) 46 | .bind(buildHandlerProvider(healthSuppliers, metricsSupplier)) 47 | .toCompletableFuture() 48 | .get(); 49 | LOGGER.info("HTTP server online at http://{}:{}/...", address, httpPort); 50 | 51 | return binding; 52 | } catch (InterruptedException | ExecutionException e) { 53 | throw new RuntimeException("Unable to start http server", e); 54 | } 55 | } 56 | 57 | private static Route buildHandlerProvider( 58 | final Collection>> healthSuppliers, 59 | final Supplier metricsSupplier) { 60 | return concat( 61 | pathPrefix( 62 | "health", 63 | () -> 64 | concat( 65 | path("smoke", () -> get(() -> complete(StatusCodes.OK))), 66 | path( 67 | "backing-services", 68 | () -> handleBackingServicesHealthRoute(healthSuppliers)), 69 | get(() -> complete(StatusCodes.OK)))), 70 | pathPrefix("metrics", () -> get(() -> complete(metricsSupplier.get())))); 71 | } 72 | 73 | private static Route handleBackingServicesHealthRoute( 74 | final Collection>> healthSuppliers) { 75 | return completeWithFuture( 76 | generateHealthReport(healthSuppliers).thenApply(HealthService::healthReportToHttpResponse)); 77 | } 78 | 79 | private static CompletableFuture generateHealthReport( 80 | final Collection>> healthSuppliers) { 81 | return CompletableFuture.supplyAsync( 82 | () -> 83 | healthSuppliers.parallelStream() 84 | .map(HealthService::fetchHealthReport) 85 | .reduce(HealthReport.empty(), HealthReport::merge)); 86 | } 87 | 88 | private static HealthReport fetchHealthReport( 89 | final Supplier> supplier) { 90 | try { 91 | return supplier.get().get(); 92 | } catch (InterruptedException | ExecutionException e) { 93 | LOGGER.warn("Unable to determine health status", e); 94 | return HealthReport.error("Unable to determine health status: " + e.getMessage()); 95 | } 96 | } 97 | 98 | private static HttpResponse healthReportToHttpResponse(final HealthReport r) { 99 | try { 100 | final String payload = 101 | writer.writeValueAsString(HealthResponse.fromErrors(r.errors().toJavaList())); 102 | return HttpResponse.create() 103 | .withStatus(StatusCodes.OK) 104 | .withEntity(HttpEntities.create(ContentTypes.APPLICATION_JSON, payload)); 105 | } catch (JsonProcessingException e) { 106 | LOGGER.error("Unable to serialize report", e); 107 | return HttpResponse.create().withStatus(StatusCodes.INTERNAL_SERVER_ERROR); 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/health/KafkaConnectionCheck.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.health; 2 | 3 | import static org.apache.commons.lang3.builder.ToStringStyle.SHORT_PREFIX_STYLE; 4 | 5 | import akka.actor.typed.ActorRef; 6 | import akka.actor.typed.Behavior; 7 | import akka.actor.typed.javadsl.Behaviors; 8 | import com.typesafe.config.Config; 9 | import io.retel.ariproxy.health.api.HealthReport; 10 | import java.time.Duration; 11 | import java.util.Arrays; 12 | import java.util.List; 13 | import java.util.Map; 14 | import java.util.Properties; 15 | import java.util.concurrent.CompletableFuture; 16 | import org.apache.commons.lang3.builder.ReflectionToStringBuilder; 17 | import org.apache.kafka.clients.CommonClientConfigs; 18 | import org.apache.kafka.clients.admin.ScramMechanism; 19 | import org.apache.kafka.clients.consumer.ConsumerConfig; 20 | import org.apache.kafka.clients.consumer.KafkaConsumer; 21 | import org.apache.kafka.common.PartitionInfo; 22 | import org.apache.kafka.common.config.SaslConfigs; 23 | import org.apache.kafka.common.errors.TimeoutException; 24 | import org.apache.kafka.common.security.auth.SecurityProtocol; 25 | import org.apache.kafka.common.serialization.StringDeserializer; 26 | 27 | public class KafkaConnectionCheck { 28 | 29 | static final String EVENTS_AND_RESPONSES_TOPIC = "events-and-responses-topic"; 30 | static final String COMMANDS_TOPIC = "commands-topic"; 31 | static final String BOOTSTRAP_SERVERS = "bootstrap-servers"; 32 | static final String CONSUMER_GROUP = "consumer-group"; 33 | 34 | private static KafkaConsumer consumer; 35 | 36 | private KafkaConnectionCheck() { 37 | throw new IllegalStateException("Utility class"); 38 | } 39 | 40 | public static Behavior create(final Config kafkaConfig) { 41 | consumer = createKafkaConsumer(kafkaConfig); 42 | 43 | final List wantedTopics = 44 | Arrays.asList( 45 | kafkaConfig.getString(COMMANDS_TOPIC), 46 | kafkaConfig.getString(EVENTS_AND_RESPONSES_TOPIC)); 47 | 48 | return Behaviors.receive(ReportKafkaConnectionHealth.class) 49 | .onMessage( 50 | ReportKafkaConnectionHealth.class, 51 | message -> reportHealth(kafkaConfig, wantedTopics, message)) 52 | .build(); 53 | } 54 | 55 | private static KafkaConsumer createKafkaConsumer(final Config kafkaConfig) { 56 | final Properties kafkaProperties = new Properties(); 57 | kafkaProperties.setProperty( 58 | ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getCanonicalName()); 59 | kafkaProperties.setProperty( 60 | ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, 61 | StringDeserializer.class.getCanonicalName()); 62 | kafkaProperties.setProperty( 63 | ConsumerConfig.GROUP_ID_CONFIG, kafkaConfig.getString(CONSUMER_GROUP)); 64 | kafkaProperties.setProperty( 65 | ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaConfig.getString(BOOTSTRAP_SERVERS)); 66 | 67 | if ("SASL_SSL".equals(kafkaConfig.getString("security.protocol"))) { 68 | kafkaProperties.setProperty( 69 | CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, SecurityProtocol.SASL_SSL.name()); 70 | kafkaProperties.setProperty( 71 | SaslConfigs.SASL_MECHANISM, ScramMechanism.SCRAM_SHA_256.mechanismName()); 72 | kafkaProperties.setProperty( 73 | SaslConfigs.SASL_JAAS_CONFIG, 74 | "org.apache.kafka.common.security.scram.ScramLoginModule required username=\"%s\" password=\"%s\";" 75 | .formatted( 76 | kafkaConfig.getString("security.user"), 77 | kafkaConfig.getString("security.password"))); 78 | } 79 | 80 | return new KafkaConsumer<>(kafkaProperties); 81 | } 82 | 83 | private static Behavior reportHealth( 84 | final Config kafkaConfig, 85 | final List wantedTopics, 86 | final ReportKafkaConnectionHealth message) { 87 | provideHealthReport(kafkaConfig.getString(BOOTSTRAP_SERVERS), wantedTopics) 88 | .thenAccept(healthReport -> message.replyTo().tell(healthReport)); 89 | 90 | return Behaviors.same(); 91 | } 92 | 93 | private static CompletableFuture provideHealthReport( 94 | final String bootstrapServers, final List neededTopics) { 95 | 96 | return CompletableFuture.supplyAsync( 97 | () -> { 98 | try { 99 | final Map> receivedTopics = 100 | consumer.listTopics(Duration.ofMillis(100)); 101 | 102 | final List missingTopics = 103 | neededTopics.stream().filter(s -> !receivedTopics.containsKey(s)).toList(); 104 | if (!missingTopics.isEmpty()) { 105 | return HealthReport.error( 106 | KafkaConnectionCheck.class, 107 | "missing topics, please create: %s".formatted(missingTopics)); 108 | } 109 | 110 | return HealthReport.ok(); 111 | 112 | } catch (TimeoutException timeoutException) { 113 | return HealthReport.error( 114 | KafkaConnectionCheck.class, 115 | "timeout during connection to servers: %s".formatted(bootstrapServers)); 116 | } 117 | }); 118 | } 119 | 120 | public record ReportKafkaConnectionHealth(ActorRef replyTo) { 121 | 122 | @Override 123 | public String toString() { 124 | return ReflectionToStringBuilder.toString(this, SHORT_PREFIX_STYLE); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/health/api/HealthReport.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.health.api; 2 | 3 | import static org.apache.commons.lang3.builder.EqualsBuilder.reflectionEquals; 4 | import static org.apache.commons.lang3.builder.HashCodeBuilder.reflectionHashCode; 5 | import static org.apache.commons.lang3.builder.ToStringBuilder.reflectionToString; 6 | import static org.apache.commons.lang3.builder.ToStringStyle.SHORT_PREFIX_STYLE; 7 | 8 | import io.vavr.collection.List; 9 | 10 | public class HealthReport { 11 | private List errors; 12 | 13 | public static HealthReport empty() { 14 | return new HealthReport(List.empty()); 15 | } 16 | 17 | public static HealthReport ok() { 18 | return empty(); 19 | } 20 | 21 | public static HealthReport error(final String error) { 22 | return new HealthReport(List.of(error)); 23 | } 24 | 25 | public static HealthReport error(final Class checkClass, final String error) { 26 | return new HealthReport(List.of("%s: %s".formatted(checkClass.getSimpleName(), error))); 27 | } 28 | 29 | private HealthReport(final List errors) { 30 | this.errors = errors; 31 | } 32 | 33 | public List errors() { 34 | return errors; 35 | } 36 | 37 | public HealthReport merge(HealthReport other) { 38 | if (other != null) { 39 | return new HealthReport(errors.appendAll(other.errors())); 40 | } 41 | return this; 42 | } 43 | 44 | @Override 45 | public String toString() { 46 | return reflectionToString(this, SHORT_PREFIX_STYLE); 47 | } 48 | 49 | @Override 50 | public boolean equals(final Object o) { 51 | return reflectionEquals(this, o); 52 | } 53 | 54 | @Override 55 | public int hashCode() { 56 | return reflectionHashCode(this); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/health/api/HealthResponse.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.health.api; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import java.util.List; 5 | import org.apache.commons.lang3.builder.ReflectionToStringBuilder; 6 | import org.apache.commons.lang3.builder.ToStringStyle; 7 | 8 | public class HealthResponse { 9 | 10 | private final List errors; 11 | private final boolean isOk; 12 | 13 | private HealthResponse(boolean isOk, List errors) { 14 | this.isOk = isOk; 15 | this.errors = errors; 16 | } 17 | 18 | @JsonProperty 19 | public boolean isOk() { 20 | return isOk; 21 | } 22 | 23 | @JsonProperty 24 | public List errors() { 25 | return errors; 26 | } 27 | 28 | public static HealthResponse fromErrors(List errors) { 29 | return new HealthResponse(errors.size() == 0, errors); 30 | } 31 | 32 | @Override 33 | public String toString() { 34 | return ReflectionToStringBuilder.toString(this, ToStringStyle.SHORT_PREFIX_STYLE); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/metrics/Metrics.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.metrics; 2 | 3 | import com.typesafe.config.Config; 4 | import com.typesafe.config.ConfigFactory; 5 | import io.micrometer.core.instrument.Clock; 6 | import io.micrometer.core.instrument.Counter; 7 | import io.micrometer.core.instrument.Gauge; 8 | import io.micrometer.core.instrument.Tag; 9 | import io.micrometer.core.instrument.Timer; 10 | import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics; 11 | import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics; 12 | import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics; 13 | import io.micrometer.core.instrument.composite.CompositeMeterRegistry; 14 | import io.micrometer.jmx.JmxConfig; 15 | import io.micrometer.jmx.JmxMeterRegistry; 16 | import io.micrometer.prometheusmetrics.PrometheusConfig; 17 | import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; 18 | import io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriCommand; 19 | import io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriCommandType; 20 | import io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriMessageType; 21 | import io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriResourceType; 22 | import io.retel.ariproxy.health.api.HealthReport; 23 | import java.time.Duration; 24 | import java.util.Collections; 25 | import java.util.List; 26 | import java.util.concurrent.CompletableFuture; 27 | import java.util.concurrent.ExecutionException; 28 | import java.util.concurrent.TimeUnit; 29 | import java.util.concurrent.TimeoutException; 30 | import java.util.function.Supplier; 31 | 32 | public final class Metrics { 33 | 34 | private static final Duration MAX_EXPECTED_DURATION = Duration.ofSeconds(10); 35 | private static final Config METRICS_CONFIG = 36 | ConfigFactory.load().getConfig("service").getConfig("metrics"); 37 | private static final Config COMMON_TAGS_CONFIG = METRICS_CONFIG.getConfig("common-tags"); 38 | private static final List COMMON_TAGS = 39 | COMMON_TAGS_CONFIG.entrySet().stream() 40 | .map(entry -> Tag.of(entry.getKey(), entry.getValue().unwrapped().toString())) 41 | .toList(); 42 | private static final Duration HEALTH_REPORT_TIMEOUT = 43 | METRICS_CONFIG.getDuration("healthReportTimeout"); 44 | private static final String BACKING_SERVICE_AVAILABILITY_METRIC_NAME = 45 | METRICS_CONFIG.getConfig("measurement-names").getString("backing-service-availability"); 46 | // Metric Names 47 | private static final String OUTGOING_REQUESTS_METRIC_NAME = "ari-proxy.outgoing.requests"; 48 | private static final String OUTGOING_REQUESTS_TIMER_METRIC_NAME = 49 | "ari-proxy.outgoing.requests.duration"; 50 | private static final String OUTGOING_REQUESTS_ERRORS_METRIC_NAME = 51 | "ari-proxy.outgoing.requests.errors"; 52 | private static final String PROCESSOR_RESTARTS_METRIC_NAME = "ari-proxy.processor.restarts"; 53 | private static final String EVENTS_METRIC_NAME = "ari-proxy.events"; 54 | 55 | // Registry 56 | private static final CompositeMeterRegistry REGISTRY = new CompositeMeterRegistry(); 57 | private static final PrometheusMeterRegistry prometheusRegistry = 58 | new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); 59 | 60 | private static final JmxMeterRegistry jmxMeterRegistry = 61 | new JmxMeterRegistry(JmxConfig.DEFAULT, Clock.SYSTEM); 62 | 63 | private static final Counter CACHE_READ_ATTEMPTS_COUNTER = 64 | REGISTRY.counter("ari-proxy.cache.read.attempts"); 65 | private static final Counter CACHE_READ_MISSES_COUNTER = 66 | REGISTRY.counter("ari-proxy.cache.read.misses"); 67 | private static final Counter CACHE_READ_ERRORS_COUNTER = 68 | REGISTRY.counter("ari-proxy.cache.read.errors"); 69 | 70 | private static final Counter PERSISTENCE_READ_ERRORS_COUNTERS = 71 | REGISTRY.counter("ari-proxy.persistence.read.errors"); 72 | private static final Timer PERSISTENCE_WRITE_DURATION_TIMER = 73 | getTimerWithHistogram( 74 | "ari-proxy.persistence.write.duration", Collections.emptyList(), MAX_EXPECTED_DURATION); 75 | 76 | private static final Counter COMMAND_RESPONSE_PROCESSOR_RESTARTS_COUNTER = 77 | REGISTRY.counter( 78 | PROCESSOR_RESTARTS_METRIC_NAME, List.of(Tag.of("processorType", "commandResponse"))); 79 | private static final Counter EVENT_PROCESSOR_RESTARTS_COUNTER = 80 | REGISTRY.counter(PROCESSOR_RESTARTS_METRIC_NAME, List.of(Tag.of("processorType", "event"))); 81 | 82 | static { 83 | REGISTRY.config().commonTags(COMMON_TAGS); 84 | REGISTRY.add(prometheusRegistry); 85 | REGISTRY.add(jmxMeterRegistry); 86 | 87 | new JvmMemoryMetrics().bindTo(REGISTRY); 88 | new JvmGcMetrics().bindTo(REGISTRY); 89 | new JvmThreadMetrics().bindTo(REGISTRY); 90 | 91 | registerAriEventCounters(); 92 | } 93 | 94 | public static void configureCallContextProviderAvailabilitySupplier( 95 | final Supplier> callContextProviderAvailability, 96 | final String backingServiceName) { 97 | Gauge.builder( 98 | BACKING_SERVICE_AVAILABILITY_METRIC_NAME, 99 | mapHealthReportToGaugeValue(callContextProviderAvailability)) 100 | .tags("backing_service", backingServiceName.toLowerCase()) 101 | .register(REGISTRY); 102 | } 103 | 104 | public static void configureKafkaAvailabilitySupplier( 105 | final Supplier> kafkaAvailability) { 106 | Gauge.builder( 107 | BACKING_SERVICE_AVAILABILITY_METRIC_NAME, 108 | mapHealthReportToGaugeValue(kafkaAvailability)) 109 | .tags("backing_service", "kafka") 110 | .register(REGISTRY); 111 | } 112 | 113 | public static void configureAriAvailabilitySupplier( 114 | final Supplier> ariAvailability) { 115 | Gauge.builder( 116 | BACKING_SERVICE_AVAILABILITY_METRIC_NAME, mapHealthReportToGaugeValue(ariAvailability)) 117 | .tags("backing_service", "asterisk") 118 | .register(REGISTRY); 119 | } 120 | 121 | private static void registerAriEventCounters() { 122 | for (final AriMessageType ariMessageType : AriMessageType.values()) { 123 | getAriEventCounter(ariMessageType); 124 | } 125 | } 126 | 127 | private static Timer getTimerWithHistogram( 128 | final String metricName, final List tags, Duration maxExpectedDuration) { 129 | return Timer.builder(metricName) 130 | .publishPercentileHistogram() 131 | .maximumExpectedValue(maxExpectedDuration) 132 | .tags(tags) 133 | .register(REGISTRY); 134 | } 135 | 136 | public static void recordAriCommandRequest( 137 | final AriCommand ariCommand, final Duration duration, final boolean isError) { 138 | final String method = ariCommand.getMethod(); 139 | final String templatedPath = 140 | AriCommandType.extractTemplatedPath(ariCommand.getUrl()).orElse("UNKNOWN"); 141 | 142 | final List tags = List.of(Tag.of("method", method), Tag.of("path", templatedPath)); 143 | 144 | REGISTRY.counter(OUTGOING_REQUESTS_METRIC_NAME, tags).increment(); 145 | if (isError) { 146 | REGISTRY.counter(OUTGOING_REQUESTS_ERRORS_METRIC_NAME, tags).increment(); 147 | } 148 | 149 | getTimerWithHistogram(OUTGOING_REQUESTS_TIMER_METRIC_NAME, tags, MAX_EXPECTED_DURATION) 150 | .record(duration); 151 | } 152 | 153 | public static void countCacheReadAttempt() { 154 | CACHE_READ_ATTEMPTS_COUNTER.increment(); 155 | } 156 | 157 | public static void countCacheReadMiss() { 158 | CACHE_READ_MISSES_COUNTER.increment(); 159 | } 160 | 161 | public static void countCacheReadError() { 162 | CACHE_READ_ERRORS_COUNTER.increment(); 163 | } 164 | 165 | public static void countPersistenceReadError() { 166 | PERSISTENCE_READ_ERRORS_COUNTERS.increment(); 167 | } 168 | 169 | public static void timePersistenceWriteDuration(final Duration duration) { 170 | PERSISTENCE_WRITE_DURATION_TIMER.record(duration); 171 | } 172 | 173 | public static void countCommandResponseProcessorRestarts() { 174 | COMMAND_RESPONSE_PROCESSOR_RESTARTS_COUNTER.increment(); 175 | } 176 | 177 | public static void countEventProcessorRestart() { 178 | EVENT_PROCESSOR_RESTARTS_COUNTER.increment(); 179 | } 180 | 181 | public static String scrapePrometheusRegistry() { 182 | return prometheusRegistry.scrape(); 183 | } 184 | 185 | public static void countAriEvent(final AriMessageType eventType) { 186 | getAriEventCounter(eventType).increment(); 187 | } 188 | 189 | private static Counter getAriEventCounter(final AriMessageType ariMessageType) { 190 | return REGISTRY.counter( 191 | EVENTS_METRIC_NAME, 192 | List.of( 193 | Tag.of("eventType", ariMessageType.name()), 194 | Tag.of( 195 | "resourceType", 196 | ariMessageType 197 | .getResourceType() 198 | .map(AriResourceType::toString) 199 | .getOrElse("NONE")))); 200 | } 201 | 202 | private static Supplier mapHealthReportToGaugeValue( 203 | final Supplier> healthReportSupplier) { 204 | return () -> { 205 | try { 206 | return healthReportSupplier 207 | .get() 208 | .thenApply(report -> report.errors().isEmpty() ? 1 : 0) 209 | .get(HEALTH_REPORT_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); 210 | } catch (InterruptedException | ExecutionException | TimeoutException e) { 211 | return 0; 212 | } 213 | }; 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/persistence/CachedKeyValueStore.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.persistence; 2 | 3 | import com.google.common.cache.CacheBuilder; 4 | import com.google.common.cache.CacheLoader; 5 | import com.google.common.cache.LoadingCache; 6 | import io.retel.ariproxy.health.api.HealthReport; 7 | import io.retel.ariproxy.metrics.Metrics; 8 | import java.util.Optional; 9 | import java.util.concurrent.CompletableFuture; 10 | import java.util.concurrent.ExecutionException; 11 | import java.util.concurrent.TimeUnit; 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | 15 | public class CachedKeyValueStore implements KeyValueStore { 16 | 17 | private static final Logger LOGGER = LoggerFactory.getLogger(CachedKeyValueStore.class); 18 | 19 | private final KeyValueStore store; 20 | private final LoadingCache> cache; 21 | 22 | public CachedKeyValueStore(final KeyValueStore store) { 23 | this.store = store; 24 | 25 | cache = 26 | CacheBuilder.newBuilder() 27 | .expireAfterWrite(6, TimeUnit.HOURS) 28 | .build( 29 | CacheLoader.from( 30 | (String key) -> { 31 | try { 32 | final Optional result = store.get(key).get(); 33 | Metrics.countCacheReadMiss(); 34 | 35 | return result; 36 | } catch (InterruptedException | ExecutionException e) { 37 | LOGGER.warn("Unable to retrieve value for key {} from store", key, e); 38 | Metrics.countPersistenceReadError(); 39 | 40 | return Optional.empty(); 41 | } 42 | })); 43 | } 44 | 45 | @Override 46 | public CompletableFuture put(final String key, final String value) { 47 | cache.put(key, Optional.of(value)); 48 | return store.put(key, value); 49 | } 50 | 51 | @Override 52 | public CompletableFuture> get(final String key) { 53 | try { 54 | Metrics.countCacheReadAttempt(); 55 | return CompletableFuture.completedFuture(cache.get(key)); 56 | } catch (ExecutionException e) { 57 | LOGGER.error("Unable to get value for key {} from cache", key, e); 58 | Metrics.countCacheReadError(); 59 | return CompletableFuture.completedFuture(Optional.empty()); 60 | } 61 | } 62 | 63 | @Override 64 | public CompletableFuture checkHealth() { 65 | return store.checkHealth(); 66 | } 67 | 68 | @Override 69 | public void close() throws Exception { 70 | store.close(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/persistence/KeyValueStore.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.persistence; 2 | 3 | import com.typesafe.config.Config; 4 | import com.typesafe.config.ConfigFactory; 5 | import io.retel.ariproxy.health.api.HealthReport; 6 | import io.vavr.control.Try; 7 | import java.util.Optional; 8 | import java.util.concurrent.CompletableFuture; 9 | 10 | public interface KeyValueStore extends AutoCloseable { 11 | CompletableFuture put(K key, V value); 12 | 13 | CompletableFuture> get(K key); 14 | 15 | CompletableFuture checkHealth(); 16 | 17 | static KeyValueStore createDefaultStore() { 18 | 19 | final Config serviceConfig = ConfigFactory.load().getConfig("service"); 20 | 21 | final String persistenceStoreClassName = 22 | serviceConfig.hasPath("persistence-store") 23 | ? serviceConfig.getString("persistence-store") 24 | : "io.retel.ariproxy.persistence.plugin.RedisPersistenceStore"; 25 | 26 | final PersistenceStore persistenceStore = 27 | Try.of(() -> Class.forName(persistenceStoreClassName)) 28 | .flatMap(clazz -> Try.of(() -> clazz.getMethod("create"))) 29 | .flatMap(method -> Try.of(() -> (PersistenceStore) method.invoke(null))) 30 | .getOrElseThrow(t -> new RuntimeException("Failed to load any PersistenceStore", t)); 31 | 32 | return new PerformanceMeteringKeyValueStore( 33 | new CachedKeyValueStore(new PersistentKeyValueStore(persistenceStore))); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/persistence/PerformanceMeteringKeyValueStore.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.persistence; 2 | 3 | import io.retel.ariproxy.health.api.HealthReport; 4 | import io.retel.ariproxy.metrics.Metrics; 5 | import java.time.Duration; 6 | import java.time.Instant; 7 | import java.util.Optional; 8 | import java.util.concurrent.CompletableFuture; 9 | 10 | public class PerformanceMeteringKeyValueStore implements KeyValueStore { 11 | 12 | private final KeyValueStore store; 13 | 14 | public PerformanceMeteringKeyValueStore(final KeyValueStore store) { 15 | this.store = store; 16 | } 17 | 18 | @Override 19 | public CompletableFuture put(final String key, final String value) { 20 | final Instant start = Instant.now(); 21 | 22 | return store 23 | .put(key, value) 24 | .thenRun( 25 | () -> Metrics.timePersistenceWriteDuration(Duration.between(start, Instant.now()))); 26 | } 27 | 28 | @Override 29 | public CompletableFuture> get(final String key) { 30 | return store.get(key); 31 | } 32 | 33 | @Override 34 | public CompletableFuture checkHealth() { 35 | return store.checkHealth(); 36 | } 37 | 38 | @Override 39 | public void close() throws Exception { 40 | store.close(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/persistence/PersistenceStore.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.persistence; 2 | 3 | import io.vavr.concurrent.Future; 4 | import io.vavr.control.Option; 5 | 6 | public interface PersistenceStore { 7 | 8 | Future set(String key, String value); 9 | 10 | Future> get(String key); 11 | 12 | void shutdown(); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/persistence/PersistentKeyValueStore.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.persistence; 2 | 3 | import io.retel.ariproxy.health.api.HealthReport; 4 | import io.vavr.Value; 5 | import java.util.Optional; 6 | import java.util.UUID; 7 | import java.util.concurrent.CompletableFuture; 8 | import org.apache.commons.lang3.StringUtils; 9 | 10 | public class PersistentKeyValueStore implements KeyValueStore { 11 | 12 | private final PersistenceStore persistenceStore; 13 | 14 | public PersistentKeyValueStore(final PersistenceStore persistenceStore) { 15 | this.persistenceStore = persistenceStore; 16 | } 17 | 18 | @Override 19 | public CompletableFuture put(final String key, final String value) { 20 | return persistenceStore.set(key, value).map(s -> null).toCompletableFuture(); 21 | } 22 | 23 | @Override 24 | public CompletableFuture> get(final String key) { 25 | return persistenceStore.get(key).map(Value::toJavaOptional).toCompletableFuture(); 26 | } 27 | 28 | @Override 29 | public CompletableFuture checkHealth() { 30 | final String key = "HEALTHCHECK_" + UUID.randomUUID(); 31 | final String value = StringUtils.reverse(key); 32 | 33 | return persistenceStore 34 | .set(key, value) 35 | .toCompletableFuture() 36 | .thenCompose(unusedValue -> persistenceStore.get(key).toCompletableFuture()) 37 | .handle( 38 | (result, error) -> { 39 | if (error != null) { 40 | return HealthReport.error( 41 | "PersistenceStoreCheck: unable to set & get value: " + error.getMessage()); 42 | } 43 | 44 | if (result.isEmpty()) { 45 | return HealthReport.error("PersistenceStoreCheck: empty result on get()"); 46 | } 47 | 48 | if (!result.get().equals(value)) { 49 | return HealthReport.error( 50 | String.format( 51 | "PersistenceStoreCheck: %s does not match expected %s", 52 | result.get(), value)); 53 | } 54 | 55 | return HealthReport.ok(); 56 | }); 57 | } 58 | 59 | @Override 60 | public void close() { 61 | persistenceStore.shutdown(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/persistence/plugin/CassandraPersistenceStore.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.persistence.plugin; 2 | 3 | import static java.lang.String.format; 4 | 5 | import com.datastax.oss.driver.api.core.CqlSession; 6 | import com.datastax.oss.driver.api.core.cql.Row; 7 | import com.datastax.oss.driver.api.core.cql.SimpleStatement; 8 | import io.retel.ariproxy.persistence.PersistenceStore; 9 | import io.vavr.concurrent.Future; 10 | import io.vavr.control.Option; 11 | 12 | public class CassandraPersistenceStore implements PersistenceStore { 13 | 14 | private static final String COLUMN_KEY = "key"; 15 | private static final String COLUMN_VALUE = "value"; 16 | private static final String TABLE_NAME = "retel"; 17 | 18 | private final CqlSession session; 19 | 20 | CassandraPersistenceStore(final CqlSession session) { 21 | this.session = session; 22 | } 23 | 24 | public static CassandraPersistenceStore create() { 25 | return new CassandraPersistenceStore(CqlSession.builder().build()); 26 | } 27 | 28 | public static String getName() { 29 | return "Cassandra"; 30 | } 31 | 32 | @Override 33 | public Future> get(final String key) { 34 | return Future.of( 35 | () -> { 36 | SimpleStatement statement = 37 | SimpleStatement.builder( 38 | format("SELECT * FROM %s WHERE %s = ?", TABLE_NAME, COLUMN_KEY)) 39 | .addPositionalValue(key) 40 | .build(); 41 | 42 | Option result = Option.of(session.execute(statement).one()); 43 | 44 | return result.map(r -> r.getString(COLUMN_VALUE)); 45 | }); 46 | } 47 | 48 | @Override 49 | public Future set(final String key, final String value) { 50 | return Future.of( 51 | () -> { 52 | SimpleStatement build = 53 | SimpleStatement.builder( 54 | format( 55 | "INSERT INTO %s(%s, %s) VALUES (?, ?)", 56 | TABLE_NAME, COLUMN_KEY, COLUMN_VALUE)) 57 | .addPositionalValue(key) 58 | .addPositionalValue(value) 59 | .build(); 60 | 61 | session.execute(build); 62 | 63 | return key; 64 | }); 65 | } 66 | 67 | @Override 68 | public void shutdown() { 69 | session.close(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/persistence/plugin/RedisPersistenceStore.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.persistence.plugin; 2 | 3 | import com.typesafe.config.Config; 4 | import com.typesafe.config.ConfigFactory; 5 | import io.lettuce.core.RedisClient; 6 | import io.lettuce.core.RedisURI; 7 | import io.lettuce.core.SetArgs.Builder; 8 | import io.lettuce.core.api.StatefulRedisConnection; 9 | import io.lettuce.core.api.sync.RedisCommands; 10 | import io.retel.ariproxy.persistence.PersistenceStore; 11 | import io.vavr.concurrent.Future; 12 | import io.vavr.control.Option; 13 | import io.vavr.control.Try; 14 | import java.time.Duration; 15 | import java.util.Objects; 16 | import java.util.function.Function; 17 | 18 | public class RedisPersistenceStore implements PersistenceStore { 19 | 20 | private static final String SERVICE = "service"; 21 | private static final String REDIS = "redis"; 22 | private static final String HOST = "host"; 23 | private static final String PORT = "port"; 24 | private static final String DB = "db"; 25 | private final Option entryTtl; 26 | 27 | private final RedisClient redisClient; 28 | 29 | public RedisPersistenceStore(RedisClient redisClient, final Option entryTtl) { 30 | Objects.requireNonNull(redisClient, "No RedisClient provided"); 31 | this.redisClient = redisClient; 32 | this.entryTtl = entryTtl; 33 | } 34 | 35 | public static RedisPersistenceStore create() { 36 | 37 | final Config cfg = ConfigFactory.load().getConfig(SERVICE).getConfig(REDIS); 38 | final String host = cfg.getString(HOST); 39 | final int port = cfg.getInt(PORT); 40 | final int db = cfg.getInt(DB); 41 | 42 | final Option entryTtl = Try.of(() -> cfg.getDuration("ttl")).toOption(); 43 | 44 | return create( 45 | RedisClient.create( 46 | RedisURI.Builder.redis(host).withPort(port).withSsl(false).withDatabase(db).build()), 47 | entryTtl); 48 | } 49 | 50 | public static RedisPersistenceStore create( 51 | RedisClient redisClient, final Option entryTtl) { 52 | return new RedisPersistenceStore(redisClient, entryTtl); 53 | } 54 | 55 | public static String getName() { 56 | return "Redis"; 57 | } 58 | 59 | @Override 60 | public Future set(String key, String value) { 61 | return executeRedisCommand( 62 | commands -> { 63 | final var setKey = commands.set(key, value, Builder.ex(21600)); 64 | entryTtl.forEach(ttl -> commands.expire(key, ttl)); 65 | return setKey; 66 | }); 67 | } 68 | 69 | @Override 70 | public Future> get(String key) { 71 | return executeRedisCommand(commands -> Option.of(commands.get(key))); 72 | } 73 | 74 | private Future executeRedisCommand(Function, T> f) { 75 | return Future.of( 76 | () -> { 77 | try (StatefulRedisConnection connection = redisClient.connect()) { 78 | return f.apply(connection.sync()); 79 | } 80 | }); 81 | } 82 | 83 | @Override 84 | public void shutdown() { 85 | this.redisClient.shutdown(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/io/retel/ariproxy/persistence/plugin/SQLitePersistenceStore.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.persistence.plugin; 2 | 3 | import com.typesafe.config.ConfigFactory; 4 | import io.retel.ariproxy.persistence.PersistenceStore; 5 | import io.vavr.concurrent.Future; 6 | import io.vavr.control.Option; 7 | import java.sql.*; 8 | import java.time.Duration; 9 | import java.time.Instant; 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | 13 | public class SQLitePersistenceStore implements PersistenceStore { 14 | 15 | private static final Logger LOGGER = LoggerFactory.getLogger(SQLitePersistenceStore.class); 16 | 17 | private static Duration entryTtl; 18 | 19 | private final Connection connection; 20 | 21 | SQLitePersistenceStore(final Connection connection) { 22 | this.connection = connection; 23 | } 24 | 25 | public static SQLitePersistenceStore create() { 26 | final var config = ConfigFactory.load().getConfig("service").getConfig("sqlite"); 27 | entryTtl = config.getDuration("ttl"); 28 | 29 | try { 30 | final var connection = DriverManager.getConnection(config.getString("url")); 31 | 32 | try (var statement = connection.createStatement()) { 33 | statement.execute("PRAGMA journal_mode=WAL;"); 34 | statement.execute( 35 | """ 36 | create table if not exists ari_proxy 37 | ( 38 | key TEXT not null 39 | primary key, 40 | value TEXT not null, 41 | created_at integer not null 42 | ); 43 | """); 44 | statement.execute( 45 | """ 46 | create index if not exists ari_proxy_created_at_index on ari_proxy (created_at desc); 47 | """); 48 | } 49 | 50 | return new SQLitePersistenceStore(connection); 51 | } catch (SQLException e) { 52 | LOGGER.error("Could not initialize SQLite database", e); 53 | throw new RuntimeException(e); 54 | } 55 | } 56 | 57 | public static String getName() { 58 | return "SQLite"; 59 | } 60 | 61 | @Override 62 | public Future> get(final String key) { 63 | return Future.of( 64 | () -> { 65 | try (final var statement = 66 | connection.prepareStatement("select value from ari_proxy where key = ?")) { 67 | 68 | statement.setString(1, key); 69 | 70 | return Option.of(statement.executeQuery().getString("value")); 71 | 72 | } catch (SQLException e) { 73 | LOGGER.error("Could not get value for key '{}'", key, e); 74 | } 75 | return Option.none(); 76 | }); 77 | } 78 | 79 | @Override 80 | public Future set(final String key, final String value) { 81 | return Future.of( 82 | () -> { 83 | try (final var statement = 84 | connection.prepareStatement( 85 | "insert into ari_proxy values(?,?,?) on conflict DO UPDATE SET value = ?," 86 | + " created_at = ?")) { 87 | 88 | statement.setString(1, key); 89 | statement.setString(2, value); 90 | statement.setLong(3, Instant.now().getEpochSecond()); 91 | statement.setString(4, value); 92 | statement.setLong(5, Instant.now().getEpochSecond()); 93 | 94 | statement.execute(); 95 | 96 | cleanupOldEntries(); 97 | } catch (SQLException e) { 98 | LOGGER.error("Could not set value '{}' for key '{}'", value, key, e); 99 | } 100 | return key; 101 | }); 102 | } 103 | 104 | private void cleanupOldEntries() { 105 | try (final var statement = 106 | connection.prepareStatement( 107 | "delete from ari_proxy where ari_proxy.created_at < unixepoch('now') - ?")) { 108 | statement.setLong(1, entryTtl.toSeconds()); 109 | statement.execute(); 110 | } catch (SQLException e) { 111 | LOGGER.error("Could not cleanup old entries", e); 112 | } 113 | } 114 | 115 | @Override 116 | public void shutdown() { 117 | try { 118 | connection.close(); 119 | } catch (SQLException e) { 120 | throw new RuntimeException(e); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | loggers = ["akka.event.slf4j.Slf4jLogger"] 3 | loglevel = "DEBUG" 4 | stdout-loglevel = "DEBUG" 5 | logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" 6 | event-handlers = ["akka.event.slf4j.Slf4jEventHandler"] 7 | log-dead-letters-during-shutdown = false 8 | coordinated-shutdown { 9 | phases { 10 | actor-system-terminate { 11 | timeout = 2s 12 | depends-on = [before-actor-system-terminate] 13 | } 14 | } 15 | } 16 | http { 17 | host-connection-pool { 18 | max-retries = 1 19 | max-connection-backoff = 1s 20 | } 21 | client { 22 | user-agent-header = ari-proxy 23 | connecting-timeout = 1s 24 | idle-timeout = 60s # the default 25 | 26 | websocket { 27 | periodic-keep-alive-mode = ping 28 | periodic-keep-alive-max-idle = 10s 29 | } 30 | } 31 | } 32 | 33 | kafka { 34 | producer { 35 | kafka-clients { 36 | max.in.flight.requests.per.connection = 1 # This should be set to ensure message order even in case of retries 37 | request.timeout.ms = 4000 38 | delivery.timeout.ms = 10000 39 | } 40 | } 41 | consumer { 42 | stop-timeout = 0 # consumer is drained explicitly 43 | kafka-clients { 44 | heartbeat.interval.ms = 3000 45 | session.timeout.ms = 10000 46 | } 47 | connection-checker.enable = true 48 | } 49 | } 50 | 51 | } 52 | 53 | service { 54 | name = "ari-proxy" 55 | websocket-uri = "ws://"${service.asterisk.server}"/ari/events?app="${service.stasis-app}"&api_key="${service.asterisk.user}":"${service.asterisk.password} 56 | 57 | rest { 58 | user = ${service.asterisk.user} 59 | password = ${service.asterisk.password} 60 | uri = "http://"${service.asterisk.server}"/ari" 61 | } 62 | 63 | kafka { 64 | security { 65 | protocol = "PLAIN" 66 | user = "" 67 | password = "" 68 | } 69 | 70 | auto-offset-reset = "earliest" 71 | parallel-consumer-max-concurrency = 1 72 | } 73 | 74 | metrics { 75 | healthReportTimeout = 100ms 76 | measurement-names { 77 | backing-service-availability = "backing_service_availability" 78 | } 79 | common-tags { 80 | # Tags to be added to prometheus measurements 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %highlight{%d{yyyy-MM-dd/HH:mm:ss,SSS/zzz}{CET} [%p] [%t] %c{1} - %msg%n%throwable} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/ArchitectureTest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy; 2 | 3 | import static com.tngtech.archunit.core.domain.JavaClass.Predicates.*; 4 | import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; 5 | 6 | import akka.actor.CoordinatedShutdown; 7 | import com.tngtech.archunit.core.importer.ImportOption.DoNotIncludeJars; 8 | import com.tngtech.archunit.core.importer.ImportOption.DoNotIncludeTests; 9 | import com.tngtech.archunit.junit.AnalyzeClasses; 10 | import com.tngtech.archunit.junit.ArchTest; 11 | import com.tngtech.archunit.lang.ArchRule; 12 | 13 | @AnalyzeClasses( 14 | packages = "io.retel.ariproxy", 15 | importOptions = {DoNotIncludeTests.class, DoNotIncludeJars.class}) 16 | public class ArchitectureTest { 17 | 18 | @ArchTest 19 | public static final ArchRule NOTHING_DEPENDS_ON_AKKA_CLASSIC = 20 | classes() 21 | .should() 22 | .onlyDependOnClassesThat( 23 | resideOutsideOfPackage("akka.actor..") 24 | .or(resideInAPackage("akka.actor.typed..")) 25 | .or(type(CoordinatedShutdown.class))); 26 | } 27 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/TestArchitectureTest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy; 2 | 3 | import static com.tngtech.archunit.core.domain.JavaClass.Predicates.*; 4 | import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; 5 | 6 | import com.tngtech.archunit.core.importer.ImportOption.DoNotIncludeJars; 7 | import com.tngtech.archunit.core.importer.ImportOption.OnlyIncludeTests; 8 | import com.tngtech.archunit.junit.AnalyzeClasses; 9 | import com.tngtech.archunit.junit.ArchTest; 10 | import com.tngtech.archunit.lang.ArchRule; 11 | 12 | @AnalyzeClasses( 13 | packages = "io.retel.ariproxy", 14 | importOptions = {OnlyIncludeTests.class, DoNotIncludeJars.class}) 15 | public class TestArchitectureTest { 16 | 17 | @ArchTest 18 | public static final ArchRule NOTHING_DEPENDS_ON_AKKA_CLASSIC = 19 | classes() 20 | .that() 21 | .haveSimpleNameNotEndingWith("ArchitectureTest") 22 | .should() 23 | .onlyDependOnClassesThat( 24 | resideOutsideOfPackage("akka.actor..") 25 | .or(resideInAPackage("akka.actor.typed..")) 26 | .or(resideInAPackage("akka.actor.testkit.typed..")) 27 | .or(type(ArchitectureTest.class))); 28 | } 29 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/TestUtils.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy; 2 | 3 | public class TestUtils { 4 | 5 | private static final String CALL_CONTEXT_KEY_PREFIX = "ari-proxy:call-context-provider"; 6 | 7 | public static String withCallContextKeyPrefix(String resourceId) { 8 | return CALL_CONTEXT_KEY_PREFIX + ":" + resourceId; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/boundary/callcontext/CallContextProviderTest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.callcontext; 2 | 3 | import static io.retel.ariproxy.TestUtils.withCallContextKeyPrefix; 4 | import static org.junit.jupiter.api.Assertions.*; 5 | 6 | import akka.actor.testkit.typed.javadsl.ActorTestKit; 7 | import akka.actor.testkit.typed.javadsl.TestProbe; 8 | import akka.actor.typed.ActorRef; 9 | import akka.pattern.StatusReply; 10 | import com.typesafe.config.ConfigFactory; 11 | import io.retel.ariproxy.boundary.callcontext.api.*; 12 | import io.retel.ariproxy.health.api.HealthReport; 13 | import io.vavr.control.Option; 14 | import java.util.HashMap; 15 | import java.util.Map; 16 | import java.util.UUID; 17 | import org.apache.commons.lang3.StringUtils; 18 | import org.junit.jupiter.api.AfterAll; 19 | import org.junit.jupiter.api.Test; 20 | 21 | class CallContextProviderTest { 22 | 23 | private static final ActorTestKit testKit = 24 | ActorTestKit.create("testKit", ConfigFactory.defaultApplication()); 25 | 26 | private static final String RESOURCE_ID = "theResourceId"; 27 | private static final String CALL_CONTEXT_FROM_DB = "theCallContextFromDB"; 28 | private static final String CALL_CONTEXT_FROM_CHANNEL_VARS = "theCallContextFromChannelVars"; 29 | 30 | @Test 31 | void verifyCreateIfMissingPolicyIsAppliedProperly() { 32 | final Map store = new HashMap<>(); 33 | final ActorRef callContextProvider = 34 | testKit.spawn(CallContextProvider.create(new MemoryKeyValueStore(store))); 35 | final TestProbe> probe = 36 | createCallContextProviderResponseTestProbe(); 37 | 38 | callContextProvider.tell( 39 | new ProvideCallContext( 40 | RESOURCE_ID, ProviderPolicy.CREATE_IF_MISSING, Option.none(), probe.getRef())); 41 | 42 | final StatusReply response = expectCalContextProviderResponse(probe); 43 | assertTrue(response.isSuccess()); 44 | assertDoesNotThrow(() -> UUID.fromString(response.getValue().callContext())); 45 | assertTrue(StringUtils.isNotBlank(store.get(withCallContextKeyPrefix(RESOURCE_ID)))); 46 | } 47 | 48 | @Test 49 | void verifyCreateIfMissingPolicyIsAppliedProperlyWhenCallContextIsProvidedInChannelVar() { 50 | final Map store = new HashMap<>(); 51 | final ActorRef callContextProvider = 52 | testKit.spawn(CallContextProvider.create(new MemoryKeyValueStore(store))); 53 | final TestProbe> probe = 54 | createCallContextProviderResponseTestProbe(); 55 | 56 | callContextProvider.tell( 57 | new ProvideCallContext( 58 | RESOURCE_ID, 59 | ProviderPolicy.CREATE_IF_MISSING, 60 | Option.some(CALL_CONTEXT_FROM_CHANNEL_VARS), 61 | probe.getRef())); 62 | 63 | final StatusReply response = expectCalContextProviderResponse(probe); 64 | assertTrue(response.isSuccess()); 65 | assertEquals(CALL_CONTEXT_FROM_CHANNEL_VARS, response.getValue().callContext()); 66 | assertEquals(CALL_CONTEXT_FROM_CHANNEL_VARS, store.get(withCallContextKeyPrefix(RESOURCE_ID))); 67 | } 68 | 69 | @Test 70 | void verifyCreateIfMissingPolicyIsAppliedProperlyWhenCallContextIsProvidedInChannelVarAndInDB() { 71 | final Map store = new HashMap<>(); 72 | store.put(withCallContextKeyPrefix(RESOURCE_ID), CALL_CONTEXT_FROM_DB); 73 | final ActorRef callContextProvider = 74 | testKit.spawn(CallContextProvider.create(new MemoryKeyValueStore(store))); 75 | final TestProbe> probe = 76 | createCallContextProviderResponseTestProbe(); 77 | 78 | callContextProvider.tell( 79 | new ProvideCallContext( 80 | RESOURCE_ID, 81 | ProviderPolicy.CREATE_IF_MISSING, 82 | Option.some(CALL_CONTEXT_FROM_CHANNEL_VARS), 83 | probe.getRef())); 84 | 85 | final StatusReply response = expectCalContextProviderResponse(probe); 86 | assertEquals(CALL_CONTEXT_FROM_CHANNEL_VARS, response.getValue().callContext()); 87 | assertEquals(CALL_CONTEXT_FROM_CHANNEL_VARS, store.get(withCallContextKeyPrefix(RESOURCE_ID))); 88 | } 89 | 90 | @Test 91 | void verifyLookupOnlyPolicyIsAppliedProperlyIfEntryAlreadyExisted() { 92 | final Map store = new HashMap<>(); 93 | store.put(withCallContextKeyPrefix(RESOURCE_ID), CALL_CONTEXT_FROM_DB); 94 | final ActorRef callContextProvider = 95 | testKit.spawn(CallContextProvider.create(new MemoryKeyValueStore(store))); 96 | final TestProbe> probe = 97 | createCallContextProviderResponseTestProbe(); 98 | 99 | callContextProvider.tell( 100 | new ProvideCallContext( 101 | RESOURCE_ID, ProviderPolicy.LOOKUP_ONLY, Option.none(), probe.getRef())); 102 | 103 | final StatusReply response = expectCalContextProviderResponse(probe); 104 | assertEquals(CALL_CONTEXT_FROM_DB, response.getValue().callContext()); 105 | assertEquals(CALL_CONTEXT_FROM_DB, store.get(withCallContextKeyPrefix(RESOURCE_ID))); 106 | } 107 | 108 | @Test 109 | void failureResponseIsReceivedWhenNoCallContextExists() { 110 | final ActorRef callContextProvider = 111 | testKit.spawn(CallContextProvider.create(new MemoryKeyValueStore())); 112 | final TestProbe> probe = 113 | createCallContextProviderResponseTestProbe(); 114 | 115 | callContextProvider.tell( 116 | new ProvideCallContext( 117 | RESOURCE_ID, ProviderPolicy.LOOKUP_ONLY, Option.none(), probe.getRef())); 118 | 119 | final StatusReply reply = expectCalContextProviderResponse(probe); 120 | assertTrue(reply.isError()); 121 | } 122 | 123 | @Test 124 | void ensureHealthReportIsGeneratedOnRequest() { 125 | final ActorRef callContextProvider = 126 | testKit.spawn(CallContextProvider.create(new MemoryKeyValueStore())); 127 | final TestProbe probe = testKit.createTestProbe(HealthReport.class); 128 | 129 | callContextProvider.tell(new ReportHealth(probe.getRef())); 130 | 131 | probe.expectMessage(HealthReport.ok()); 132 | } 133 | 134 | @AfterAll 135 | public static void cleanup() { 136 | testKit.shutdownTestKit(); 137 | } 138 | 139 | @SuppressWarnings("unchecked") 140 | private static TestProbe> 141 | createCallContextProviderResponseTestProbe() { 142 | return (TestProbe>) 143 | (TestProbe) testKit.createTestProbe(StatusReply.class); 144 | } 145 | 146 | @SuppressWarnings("unchecked") 147 | private StatusReply expectCalContextProviderResponse( 148 | final TestProbe> probe) { 149 | return (StatusReply) probe.expectMessageClass(StatusReply.class); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/boundary/callcontext/MemoryKeyValueStore.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.callcontext; 2 | 3 | import static java.util.Collections.singletonMap; 4 | import static java.util.concurrent.CompletableFuture.completedFuture; 5 | 6 | import io.retel.ariproxy.health.api.HealthReport; 7 | import io.retel.ariproxy.persistence.KeyValueStore; 8 | import java.util.HashMap; 9 | import java.util.Map; 10 | import java.util.Optional; 11 | import java.util.concurrent.CompletableFuture; 12 | 13 | public class MemoryKeyValueStore implements KeyValueStore { 14 | 15 | private final Map store; 16 | 17 | public MemoryKeyValueStore() { 18 | this(new HashMap<>()); 19 | } 20 | 21 | public MemoryKeyValueStore(final Map store) { 22 | this.store = store; 23 | } 24 | 25 | public MemoryKeyValueStore(final String resourceId, final String callContext) { 26 | this(new HashMap<>(singletonMap(resourceId, callContext))); 27 | } 28 | 29 | @Override 30 | public CompletableFuture put(final String key, final String value) { 31 | store.put(key, value); 32 | return completedFuture(null); 33 | } 34 | 35 | @Override 36 | public CompletableFuture> get(final String key) { 37 | return completedFuture(Optional.ofNullable(store.get(key))); 38 | } 39 | 40 | @Override 41 | public CompletableFuture checkHealth() { 42 | return completedFuture(HealthReport.ok()); 43 | } 44 | 45 | @Override 46 | public void close() { 47 | // intentionally left blank 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/boundary/callcontext/TestableCallContextProvider.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.callcontext; 2 | 3 | import akka.actor.testkit.typed.javadsl.ActorTestKit; 4 | import akka.actor.testkit.typed.javadsl.TestProbe; 5 | import akka.actor.typed.ActorRef; 6 | import akka.actor.typed.Signal; 7 | import akka.actor.typed.javadsl.Behaviors; 8 | import io.retel.ariproxy.boundary.callcontext.api.CallContextProviderMessage; 9 | import io.retel.ariproxy.persistence.KeyValueStore; 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | 13 | public final class TestableCallContextProvider { 14 | 15 | private static final Logger LOGGER = LoggerFactory.getLogger(TestableCallContextProvider.class); 16 | 17 | private final TestProbe probe; 18 | private final ActorRef ref; 19 | 20 | public TestableCallContextProvider(final ActorTestKit testKit) { 21 | this(testKit, new MemoryKeyValueStore()); 22 | } 23 | 24 | public TestableCallContextProvider( 25 | final ActorTestKit testKit, final KeyValueStore store) { 26 | final ActorRef actualCallContextProvider = 27 | testKit.spawn(CallContextProvider.create(store)); 28 | probe = testKit.createTestProbe(CallContextProviderMessage.class); 29 | 30 | ref = 31 | testKit.spawn( 32 | Behaviors.receive(CallContextProviderMessage.class) 33 | .onMessage( 34 | CallContextProviderMessage.class, 35 | msg -> { 36 | LOGGER.debug("Received message: {}", msg); 37 | probe.ref().tell(msg); 38 | actualCallContextProvider.tell(msg); 39 | 40 | return Behaviors.same(); 41 | }) 42 | .onSignal( 43 | Signal.class, 44 | signal -> { 45 | LOGGER.debug("Received signal: {}", signal); 46 | return Behaviors.same(); 47 | }) 48 | .build()); 49 | } 50 | 51 | public TestProbe probe() { 52 | return probe; 53 | } 54 | 55 | public ActorRef ref() { 56 | return ref; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/boundary/commandsandresponses/AriCommandResponseProcessingTest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses; 2 | 3 | import static org.hamcrest.CoreMatchers.is; 4 | import static org.hamcrest.MatcherAssert.assertThat; 5 | import static org.junit.jupiter.api.Assertions.assertEquals; 6 | import static org.junit.jupiter.api.Assertions.assertTrue; 7 | 8 | import akka.Done; 9 | import akka.actor.testkit.typed.javadsl.ActorTestKit; 10 | import akka.actor.testkit.typed.javadsl.TestProbe; 11 | import com.fasterxml.jackson.databind.ObjectMapper; 12 | import com.fasterxml.jackson.databind.ObjectReader; 13 | import com.typesafe.config.ConfigFactory; 14 | import io.retel.ariproxy.boundary.callcontext.TestableCallContextProvider; 15 | import io.retel.ariproxy.boundary.callcontext.api.CallContextProviderMessage; 16 | import io.retel.ariproxy.boundary.callcontext.api.RegisterCallContext; 17 | import io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriCommand; 18 | import io.vavr.control.Try; 19 | import java.io.IOException; 20 | import java.time.Duration; 21 | import org.junit.jupiter.api.AfterAll; 22 | import org.junit.jupiter.api.Test; 23 | 24 | class AriCommandResponseProcessingTest { 25 | 26 | private static final ActorTestKit testKit = 27 | ActorTestKit.create("testKit", ConfigFactory.defaultApplication()); 28 | private static final ObjectMapper mapper = new ObjectMapper(); 29 | private static final ObjectReader ariCommandReader = mapper.readerFor(AriCommand.class); 30 | 31 | private static final String CALL_CONTEXT = "theCallContext"; 32 | 33 | @Test 34 | void registerCallContextDoesNothingWhenItShouldnt() { 35 | final TestProbe callContextProviderProbe = 36 | testKit.createTestProbe(CallContextProviderMessage.class); 37 | 38 | final Try result = 39 | AriCommandResponseProcessing.registerCallContext( 40 | callContextProviderProbe.getRef(), 41 | CALL_CONTEXT, 42 | new AriCommand(null, "/channels/CHANNEL_ID/answer", null)); 43 | 44 | assertTrue(result.isSuccess()); 45 | callContextProviderProbe.expectNoMessage(Duration.ofMillis(500)); 46 | } 47 | 48 | @Test 49 | void registerCallContextRegistersANewCallContextIfTheAriCommandTypeNecessitatesIt() { 50 | final TestableCallContextProvider callContextProvider = 51 | new TestableCallContextProvider(testKit); 52 | 53 | final Try result = 54 | AriCommandResponseProcessing.registerCallContext( 55 | callContextProvider.ref(), 56 | CALL_CONTEXT, 57 | new AriCommand("POST", "/channels/CHANNEL_ID/play/PLAYBACK_ID", null)); 58 | 59 | assertTrue(result.isSuccess()); 60 | final RegisterCallContext registerCallContext = 61 | callContextProvider.probe().expectMessageClass(RegisterCallContext.class); 62 | assertThat(registerCallContext.resourceId(), is("PLAYBACK_ID")); 63 | assertThat(registerCallContext.callContext(), is(CALL_CONTEXT)); 64 | } 65 | 66 | @Test 67 | void doesNotTryToRegisterACallContextForDeleteRequests() { 68 | final TestableCallContextProvider callContextProvider = 69 | new TestableCallContextProvider(testKit); 70 | 71 | final Try result = 72 | AriCommandResponseProcessing.registerCallContext( 73 | callContextProvider.ref(), 74 | CALL_CONTEXT, 75 | new AriCommand("DELETE", "/channels/CHANNEL_ID", null)); 76 | 77 | assertTrue(result.isSuccess()); 78 | callContextProvider.probe().expectNoMessage(); 79 | } 80 | 81 | @Test 82 | void registerCallContextThrowsARuntimeExceptionIfTheAriCommandIsMalformed() { 83 | final TestProbe callContextProviderProbe = 84 | testKit.createTestProbe(CallContextProviderMessage.class); 85 | 86 | final Try result = 87 | AriCommandResponseProcessing.registerCallContext( 88 | callContextProviderProbe.ref(), null, new AriCommand("POST", "/channels", null)); 89 | 90 | assertTrue(result.isFailure()); 91 | } 92 | 93 | @Test 94 | void ensureFallBackToBodyExtractorWorksAsExpected() throws IOException { 95 | final TestableCallContextProvider callContextProvider = 96 | new TestableCallContextProvider(testKit); 97 | final String json = 98 | "{ \"method\":\"POST\", \"url\":\"/channels/CHANNEL_ID/record\"," 99 | + " \"body\":{\"name\":\"RECORD_NAME\"}}"; 100 | final AriCommand ariCommand = ariCommandReader.readValue(json); 101 | 102 | final Try result = 103 | AriCommandResponseProcessing.registerCallContext( 104 | callContextProvider.ref(), CALL_CONTEXT, ariCommand); 105 | 106 | assertTrue(result.isSuccess()); 107 | final RegisterCallContext registerCallContext = 108 | callContextProvider.probe().expectMessageClass(RegisterCallContext.class); 109 | assertEquals("RECORD_NAME", registerCallContext.resourceId()); 110 | assertEquals(CALL_CONTEXT, registerCallContext.callContext()); 111 | } 112 | 113 | @Test 114 | void ensureFallBackToBodyExtractorWorksAsExpectedForChannelCreate() throws IOException { 115 | final TestableCallContextProvider callContextProvider = 116 | new TestableCallContextProvider(testKit); 117 | final String json = 118 | "{ \"method\":\"POST\", \"url\":\"/channels/create\"," 119 | + " \"body\":{\"channelId\":\"channel-Id\"}}"; 120 | final AriCommand ariCommand = ariCommandReader.readValue(json); 121 | 122 | final Try res = 123 | AriCommandResponseProcessing.registerCallContext( 124 | callContextProvider.ref(), CALL_CONTEXT, ariCommand); 125 | 126 | assertTrue(res.isSuccess()); 127 | 128 | final RegisterCallContext registerCallContext = 129 | callContextProvider.probe().expectMessageClass(RegisterCallContext.class); 130 | assertThat(registerCallContext.resourceId(), is("channel-Id")); 131 | assertThat(registerCallContext.callContext(), is(CALL_CONTEXT)); 132 | } 133 | 134 | @AfterAll 135 | public static void cleanup() { 136 | testKit.shutdownTestKit(); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/boundary/commandsandresponses/AriCommandResponseProcessorTest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertEquals; 4 | import static org.junit.jupiter.api.Assertions.assertTrue; 5 | 6 | import akka.Done; 7 | import akka.actor.testkit.typed.javadsl.ActorTestKit; 8 | import akka.actor.testkit.typed.javadsl.TestProbe; 9 | import akka.actor.typed.ActorRef; 10 | import akka.http.javadsl.model.HttpResponse; 11 | import akka.http.javadsl.model.StatusCode; 12 | import akka.http.javadsl.model.StatusCodes; 13 | import akka.pattern.StatusReply; 14 | import akka.stream.StreamTcpException; 15 | import akka.stream.javadsl.Sink; 16 | import com.typesafe.config.ConfigFactory; 17 | import io.retel.ariproxy.AriCommandMessage; 18 | import io.retel.ariproxy.boundary.callcontext.TestableCallContextProvider; 19 | import java.io.File; 20 | import java.io.IOException; 21 | import java.nio.file.Files; 22 | import java.util.ArrayList; 23 | import java.util.List; 24 | import java.util.concurrent.CompletableFuture; 25 | import java.util.concurrent.CompletionStage; 26 | import java.util.stream.Stream; 27 | import org.apache.kafka.clients.producer.ProducerRecord; 28 | import org.codehaus.jackson.map.ObjectMapper; 29 | import org.junit.jupiter.api.AfterEach; 30 | import org.junit.jupiter.api.BeforeEach; 31 | import org.junit.jupiter.api.Test; 32 | import org.junit.jupiter.api.extension.ExtensionContext; 33 | import org.junit.jupiter.params.ParameterizedTest; 34 | import org.junit.jupiter.params.provider.Arguments; 35 | import org.junit.jupiter.params.provider.ArgumentsProvider; 36 | import org.junit.jupiter.params.provider.ArgumentsSource; 37 | 38 | class AriCommandResponseProcessorTest { 39 | 40 | private static ActorTestKit testKit; 41 | private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); 42 | 43 | @BeforeEach 44 | void setup() { 45 | testKit = ActorTestKit.create("testKit", ConfigFactory.defaultApplication()); 46 | } 47 | 48 | @AfterEach 49 | void tearDown() { 50 | testKit.shutdownTestKit(); 51 | } 52 | 53 | @Test 54 | public void handleBrokenMessage() { 55 | 56 | final Sink, CompletionStage> ignore = 57 | Sink.foreach(param -> assertEquals(param, new Object())); 58 | 59 | final TestableCallContextProvider callContextProvider = 60 | new TestableCallContextProvider(testKit); 61 | 62 | final ActorRef ariCommandResponseProcessor = 63 | testKit.spawn( 64 | AriCommandResponseProcessor.create( 65 | requestAndContext -> 66 | CompletableFuture.completedFuture( 67 | HttpResponse.create().withStatus(StatusCodes.OK).withEntity("")), 68 | callContextProvider.ref(), 69 | ignore), 70 | "ari-command-response-processor"); 71 | 72 | final TestProbe> replyProbe = testKit.createTestProbe("replyProbe"); 73 | ariCommandResponseProcessor.tell(new AriCommandMessage("", replyProbe.getRef())); 74 | 75 | final StatusReply statusReply = replyProbe.expectMessageClass(StatusReply.class); 76 | assertTrue(statusReply.isError()); 77 | } 78 | 79 | @Test 80 | public void handleTCPStreamException() throws IOException { 81 | 82 | final String inputString = 83 | loadJsonAsString("messages/commands/bridgeCreateCommandWithBody.json"); 84 | final String outputString = 85 | loadJsonAsString("messages/responses/bridgeCreateRequestFailedResponse.json"); 86 | 87 | final List> producedRecords = new ArrayList<>(); 88 | final Sink, CompletionStage> ignore = 89 | Sink.foreach(producedRecords::add); 90 | 91 | final TestableCallContextProvider callContextProvider = 92 | new TestableCallContextProvider(testKit); 93 | 94 | final ActorRef ariCommandResponseProcessor = 95 | testKit.spawn( 96 | AriCommandResponseProcessor.create( 97 | (request) -> 98 | CompletableFuture.supplyAsync( 99 | () -> { 100 | throw new StreamTcpException( 101 | "Tcp command [Connect(api.example.com:443,None,List(),Some(10" 102 | + " milliseconds),true)] failed because of" 103 | + " akka.io.TcpOutgoingConnection$$anon$2: Connect timeout of" 104 | + " Some(10 milliseconds) expired"); 105 | }), 106 | callContextProvider.ref(), 107 | ignore), 108 | "ari-command-response-processor"); 109 | 110 | final TestProbe> replyProbe = testKit.createTestProbe("replyProbe"); 111 | ariCommandResponseProcessor.tell(new AriCommandMessage(inputString, replyProbe.getRef())); 112 | 113 | final StatusReply statusReply = replyProbe.expectMessageClass(StatusReply.class); 114 | assertTrue(statusReply.isSuccess()); 115 | 116 | replyProbe.expectNoMessage(); 117 | 118 | assertEquals(1, producedRecords.size(), "expected 1 produced record"); 119 | assertEquals( 120 | OBJECT_MAPPER.readTree(outputString), 121 | OBJECT_MAPPER.readTree(producedRecords.get(0).value())); 122 | } 123 | 124 | @ParameterizedTest() 125 | @ArgumentsSource(ResponseArgumentsProvider.class) 126 | public void handleBridgeCreationFailed( 127 | final String inputFile, 128 | final String outputFile, 129 | final StatusCode statusCode, 130 | final String responseBody) 131 | throws IOException { 132 | 133 | final String inputString = loadJsonAsString(inputFile); 134 | final String outputString = loadJsonAsString(outputFile); 135 | 136 | final List> producedRecords = new ArrayList<>(); 137 | final Sink, CompletionStage> ignore = 138 | Sink.foreach(producedRecords::add); 139 | 140 | final TestableCallContextProvider callContextProvider = 141 | new TestableCallContextProvider(testKit); 142 | 143 | final ActorRef ariCommandResponseProcessor = 144 | testKit.spawn( 145 | AriCommandResponseProcessor.create( 146 | requestAndContext -> 147 | CompletableFuture.completedFuture( 148 | HttpResponse.create().withStatus(statusCode).withEntity(responseBody)), 149 | callContextProvider.ref(), 150 | ignore), 151 | "ari-command-response-processor"); 152 | 153 | final TestProbe> replyProbe = testKit.createTestProbe("replyProbe"); 154 | ariCommandResponseProcessor.tell(new AriCommandMessage(inputString, replyProbe.getRef())); 155 | 156 | final StatusReply statusReply = replyProbe.expectMessageClass(StatusReply.class); 157 | assertTrue(statusReply.isSuccess(), "StatusReply is successful"); 158 | 159 | replyProbe.expectNoMessage(); 160 | 161 | assertEquals(1, producedRecords.size(), "expected 1 produced record"); 162 | assertEquals( 163 | OBJECT_MAPPER.readTree(outputString), 164 | OBJECT_MAPPER.readTree(producedRecords.get(0).value())); 165 | } 166 | 167 | static class ResponseArgumentsProvider implements ArgumentsProvider { 168 | 169 | @Override 170 | public Stream provideArguments(ExtensionContext context) { 171 | return Stream.of( 172 | Arguments.of( 173 | "messages/commands/bridgeCreateCommandWithBody.json", 174 | "messages/responses/bridgeCreateResponseWithBody.json", 175 | StatusCodes.OK, 176 | loadJsonAsString("messages/ari/responses/bridgeCreateResponse.json")), 177 | Arguments.of( 178 | "messages/commands/channelPlaybackCommand.json", 179 | "messages/responses/channelPlaybackResponse.json", 180 | StatusCodes.OK, 181 | "{ \"key\":\"value\" }"), 182 | Arguments.of( 183 | "messages/commands/channelAnswerCommand.json", 184 | "messages/responses/channelAnswerResponse.json", 185 | StatusCodes.NO_CONTENT, 186 | ""), 187 | Arguments.of( 188 | "messages/commands/channelDeleteWithReasonCommand.json", 189 | "messages/responses/channelDeleteWithReasonResponse.json", 190 | StatusCodes.NO_CONTENT, 191 | ""), 192 | Arguments.of( 193 | "messages/commands/channelAnswerCommandWithoutCommandId.json", 194 | "messages/responses/channelAnswerResponseWithoutCommandId.json", 195 | StatusCodes.NO_CONTENT, 196 | "")); 197 | } 198 | } 199 | 200 | private static String loadJsonAsString(final String fileName) { 201 | final ClassLoader classLoader = AriCommandResponseProcessorTest.class.getClassLoader(); 202 | final File file = new File(classLoader.getResource(fileName).getFile()); 203 | try { 204 | return new String(Files.readAllBytes(file.toPath())); 205 | } catch (IOException e) { 206 | throw new IllegalStateException("Unable to load file " + fileName, e); 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/AriCommandTypeTest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import static io.retel.ariproxy.boundary.commandsandresponses.auxiliary.AriCommandType.*; 4 | import static io.vavr.API.None; 5 | import static io.vavr.API.Some; 6 | import static org.hamcrest.CoreMatchers.instanceOf; 7 | import static org.hamcrest.CoreMatchers.is; 8 | import static org.hamcrest.MatcherAssert.assertThat; 9 | import static org.junit.jupiter.api.Assertions.*; 10 | 11 | import io.vavr.control.Option; 12 | import io.vavr.control.Try; 13 | import io.vavr.control.Try.Failure; 14 | import java.util.stream.Stream; 15 | import org.junit.jupiter.api.Test; 16 | import org.junit.jupiter.params.ParameterizedTest; 17 | import org.junit.jupiter.params.provider.Arguments; 18 | import org.junit.jupiter.params.provider.EnumSource; 19 | import org.junit.jupiter.params.provider.EnumSource.Mode; 20 | import org.junit.jupiter.params.provider.MethodSource; 21 | 22 | // see: https://wiki.asterisk.org/wiki/display/AST/Asterisk+15+ARI 23 | class AriCommandTypeTest { 24 | 25 | private static final String BRIDGE_ID = "BRIDGE_ID"; 26 | private static final String CHANNEL_ID = "CHANNEL_ID"; 27 | private static final String PLAYBACK_ID = "PLAYBACK_ID"; 28 | private static final String RECORDING_NAME = "RECORDING_NAME"; 29 | private static final String SNOOP_ID = "SNOOP_ID"; 30 | 31 | private static final String BODY_WITH_BRIDGE_ID = 32 | String.format("{ \"bridgeId\": \"%s\" }", BRIDGE_ID); 33 | private static final String BODY_WITH_CHANNEL_ID = 34 | String.format("{ \"channelId\": \"%s\" }", CHANNEL_ID); 35 | private static final String BODY_WITH_PLAYBACK_ID = 36 | String.format("{ \"playbackId\": \"%s\" }", PLAYBACK_ID); 37 | private static final String BODY_WITH_RECORDING_NAME = 38 | String.format("{ \"name\": \"%s\" }", RECORDING_NAME); 39 | private static final String BODY_WITH_SNOOP_ID = 40 | String.format("{ \"snoopId\": \"%s\" }", SNOOP_ID); 41 | 42 | private static final String BRIDGE_CREATION_URI = "/bridges"; 43 | private static final String BRIDGE_CREATION_URI_WITH_ID = String.format("/bridges/%s", BRIDGE_ID); 44 | private static final String PLAYBACK_ON_BRIDGE_URI = String.format("/bridges/%s/play", BRIDGE_ID); 45 | private static final String PLAYBACK_ON_BRIDGE_URI_WITH_ID = 46 | String.format("/bridges/%s/play/%s", BRIDGE_ID, PLAYBACK_ID); 47 | private static final String RECORDING_ON_BRIDGE_URI = 48 | String.format("/bridges/%s/record", BRIDGE_ID); 49 | 50 | private static final String CHANNEL_CREATION_URI = "/channels"; 51 | private static final String CHANNEL_CREATION_URI_ALT = "/channels/create"; 52 | private static final String CHANNEL_CREATION_URI_WITH_ID = 53 | String.format("/channels/%s", CHANNEL_ID); 54 | private static final String CHANNEL_ANSWER_URI_WITH_ID = 55 | String.format("/channels/%s/answer", CHANNEL_ID); 56 | private static final String PLAYBACK_ON_CHANNEL_URI = 57 | String.format("/channels/%s/play", CHANNEL_ID); 58 | private static final String PLAYBACK_ON_CHANNEL_URI_WIH_ID = 59 | String.format("/channels/%s/play/%s", CHANNEL_ID, PLAYBACK_ID); 60 | private static final String RECORDING_ON_CHANNEL_URI = 61 | String.format("/channels/%s/record", CHANNEL_ID); 62 | private static final String SNOOPING_ON_CHANNEL = String.format("/channels/%s/snoop", CHANNEL_ID); 63 | private static final String SNOOPING_ON_CHANNEL_WITH_ID = 64 | String.format("/channels/%s/snoop/%s", CHANNEL_ID, SNOOP_ID); 65 | 66 | private static final String INVALID_COMMAND_URI = "/invalid-command-uri"; 67 | private static final String INVALID_COMMAND_BODY = "INVALID JSON"; 68 | 69 | @ParameterizedTest 70 | @MethodSource("commandUriProvider") 71 | void ensureTheCorrectTypeIsInferedFromTheCommandUri(AriCommandType type, String uri) { 72 | assertSame(type, AriCommandType.fromRequestUri(uri)); 73 | } 74 | 75 | @ParameterizedTest 76 | @MethodSource("commandUriWithIdProvider") 77 | void ensureExtractResourceIdFromUriWorksForAnyType(String uri, String expectedResourceId) { 78 | assertThat( 79 | AriCommandType.fromRequestUri(uri).extractResourceIdFromUri(uri), 80 | is(Some(expectedResourceId))); 81 | } 82 | 83 | @ParameterizedTest 84 | @MethodSource("commandBodyProvider") 85 | void ensureExtractResourceIdFromBodyWorksForAnyType( 86 | AriCommandType type, String body, String expectedResourceId) { 87 | assertThat(type.extractResourceIdFromBody(body), is(Some(Try.success(expectedResourceId)))); 88 | } 89 | 90 | @ParameterizedTest 91 | @EnumSource( 92 | value = AriCommandType.class, 93 | mode = Mode.EXCLUDE, 94 | names = {"UNKNOWN"}) 95 | void ensureInvalidUriAndBodyResultInAFailure(AriCommandType type) { 96 | assertAll( 97 | String.format("Extractors for type=%s", type), 98 | () -> assertEquals(Option.none(), type.extractResourceIdFromUri(INVALID_COMMAND_URI)), 99 | () -> 100 | assertThat( 101 | type.extractResourceIdFromBody(INVALID_COMMAND_BODY).get(), 102 | instanceOf(Failure.class))); 103 | } 104 | 105 | @Test 106 | void ensureCommandsNotCreatingANewResourceResultInANone() { 107 | assertAll( 108 | () -> assertThat(UNKNOWN.extractResourceIdFromUri(INVALID_COMMAND_URI), is(None())), 109 | () -> assertThat(UNKNOWN.extractResourceIdFromBody(INVALID_COMMAND_BODY), is(None()))); 110 | } 111 | 112 | private static Stream commandUriProvider() { 113 | return Stream.of( 114 | Arguments.of(BRIDGE_CREATION, BRIDGE_CREATION_URI), 115 | Arguments.of(BRIDGE_CREATION, BRIDGE_CREATION_URI_WITH_ID), 116 | Arguments.of(CHANNEL_CREATION, CHANNEL_CREATION_URI), 117 | Arguments.of(CHANNEL_CREATION, CHANNEL_CREATION_URI_ALT), 118 | Arguments.of(CHANNEL_CREATION, CHANNEL_CREATION_URI_WITH_ID), 119 | Arguments.of(CHANNEL, CHANNEL_ANSWER_URI_WITH_ID), 120 | Arguments.of(PLAYBACK_CREATION, PLAYBACK_ON_BRIDGE_URI), 121 | Arguments.of(PLAYBACK_CREATION, PLAYBACK_ON_BRIDGE_URI_WITH_ID), 122 | Arguments.of(PLAYBACK_CREATION, PLAYBACK_ON_CHANNEL_URI), 123 | Arguments.of(PLAYBACK_CREATION, PLAYBACK_ON_CHANNEL_URI_WIH_ID), 124 | Arguments.of(RECORDING_CREATION, RECORDING_ON_CHANNEL_URI), 125 | Arguments.of(RECORDING_CREATION, RECORDING_ON_BRIDGE_URI), 126 | Arguments.of(SNOOPING_CREATION, SNOOPING_ON_CHANNEL), 127 | Arguments.of(SNOOPING_CREATION, SNOOPING_ON_CHANNEL_WITH_ID), 128 | Arguments.of(UNKNOWN, INVALID_COMMAND_URI)); 129 | } 130 | 131 | private static Stream commandUriWithIdProvider() { 132 | return Stream.of( 133 | Arguments.of(BRIDGE_CREATION_URI_WITH_ID, BRIDGE_ID), 134 | Arguments.of(CHANNEL_CREATION_URI_WITH_ID, CHANNEL_ID), 135 | Arguments.of(PLAYBACK_ON_BRIDGE_URI_WITH_ID, PLAYBACK_ID), 136 | Arguments.of(PLAYBACK_ON_CHANNEL_URI_WIH_ID, PLAYBACK_ID), 137 | Arguments.of(SNOOPING_ON_CHANNEL_WITH_ID, SNOOP_ID)); 138 | } 139 | 140 | private static Stream commandBodyProvider() { 141 | return Stream.of( 142 | Arguments.of(BRIDGE, BODY_WITH_BRIDGE_ID, BRIDGE_ID), 143 | Arguments.of(CHANNEL, BODY_WITH_CHANNEL_ID, CHANNEL_ID), 144 | Arguments.of(PLAYBACK, BODY_WITH_PLAYBACK_ID, PLAYBACK_ID), 145 | Arguments.of(RECORDING_CREATION, BODY_WITH_RECORDING_NAME, RECORDING_NAME), 146 | Arguments.of(SNOOPING_CREATION, BODY_WITH_SNOOP_ID, SNOOP_ID)); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/boundary/commandsandresponses/auxiliary/AriMessageTypeTest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.commandsandresponses.auxiliary; 2 | 3 | import static io.vavr.API.None; 4 | import static io.vavr.API.Some; 5 | import static org.hamcrest.CoreMatchers.instanceOf; 6 | import static org.hamcrest.CoreMatchers.is; 7 | import static org.hamcrest.MatcherAssert.assertThat; 8 | import static org.mockito.Mockito.mock; 9 | 10 | import com.fasterxml.jackson.databind.JsonNode; 11 | import com.fasterxml.jackson.databind.ObjectMapper; 12 | import com.fasterxml.jackson.databind.ObjectReader; 13 | import io.vavr.control.Try; 14 | import java.io.IOException; 15 | import java.util.stream.Stream; 16 | import org.hamcrest.MatcherAssert; 17 | import org.junit.jupiter.api.Test; 18 | import org.junit.jupiter.params.ParameterizedTest; 19 | import org.junit.jupiter.params.provider.Arguments; 20 | import org.junit.jupiter.params.provider.EnumSource; 21 | import org.junit.jupiter.params.provider.EnumSource.Mode; 22 | import org.junit.jupiter.params.provider.MethodSource; 23 | 24 | // see: 25 | // https://wiki.asterisk.org/wiki/display/AST/Asterisk+15+REST+Data+Models#Asterisk15RESTDataModels-Message 26 | class AriMessageTypeTest { 27 | 28 | private static final ObjectReader reader = new ObjectMapper().reader(); 29 | 30 | private static final String BRIDGE_ID = "BRIDGE_ID"; 31 | private static final String CHANNEL_ID = "CHANNEL_ID"; 32 | private static final String PEER_ID = "SNOOP_ID"; 33 | private static final String PLAYBACK_ID = "PLAYBACK_ID"; 34 | private static final String RECORDING_NAME = "RECORDING_NAME"; 35 | 36 | private static final String BODY_WITH_BRIDGE_ID = 37 | String.format("{ \"bridge\": { \"id\": \"%s\" } }", BRIDGE_ID); 38 | private static final String BODY_WITH_CHANNEL_ID = 39 | String.format("{ \"channel\": { \"id\": \"%s\" } }", CHANNEL_ID); 40 | private static final String BODY_WITH_PEER_ID = 41 | String.format("{ \"peer\": { \"id\": \"%s\" } }", PEER_ID); 42 | private static final String BODY_WITH_PLAYBACK_ID = 43 | String.format("{ \"playback\": { \"id\": \"%s\" } }", PLAYBACK_ID); 44 | private static final String BODY_WITH_RECORDING_NAME = 45 | String.format("{ \"recording\": { \"name\": \"%s\" } }", RECORDING_NAME); 46 | 47 | @ParameterizedTest 48 | @MethodSource("messageBodyProvider") 49 | void ensureExtractResourceIdFromBodyWorksForAnyType( 50 | String type, String body, String expectedResourceId) throws IOException { 51 | assertThat( 52 | AriMessageType.fromType(type).extractResourceIdFromBody(reader.readTree(body)), 53 | is(Some(Try.success(expectedResourceId)))); 54 | } 55 | 56 | @ParameterizedTest 57 | @EnumSource( 58 | value = AriMessageType.class, 59 | mode = Mode.INCLUDE, 60 | names = {"APPLICATION_REPLACED"}) 61 | void ensureMessageTypesWithoutAnExtractorResultInANone(AriMessageType type) { 62 | assertThat(type.extractResourceIdFromBody(mock(JsonNode.class)), is(None())); 63 | } 64 | 65 | @Test 66 | void ensureUnknownMessageResultsInRuntimeException() { 67 | MatcherAssert.assertThat( 68 | AriMessageType.UNKNOWN.extractResourceIdFromBody(mock(JsonNode.class)).get().getCause(), 69 | instanceOf(RuntimeException.class)); 70 | } 71 | 72 | private static Stream messageBodyProvider() { 73 | return Stream.of( 74 | Arguments.of("BridgeBlindTransfer", BODY_WITH_CHANNEL_ID, CHANNEL_ID), 75 | Arguments.of("BridgeCreated", BODY_WITH_BRIDGE_ID, BRIDGE_ID), 76 | Arguments.of("BridgeDestroyed", BODY_WITH_BRIDGE_ID, BRIDGE_ID), 77 | Arguments.of("BridgeMerged", BODY_WITH_BRIDGE_ID, BRIDGE_ID), 78 | Arguments.of("BridgeVideoSourceChanged", BODY_WITH_BRIDGE_ID, BRIDGE_ID), 79 | Arguments.of("ChannelEnteredBridge", BODY_WITH_BRIDGE_ID, BRIDGE_ID), 80 | Arguments.of("ChannelLeftBridge", BODY_WITH_BRIDGE_ID, BRIDGE_ID), 81 | Arguments.of("ChannelCallerId", BODY_WITH_CHANNEL_ID, CHANNEL_ID), 82 | Arguments.of("ChannelConnectedLine", BODY_WITH_CHANNEL_ID, CHANNEL_ID), 83 | Arguments.of("ChannelCreated", BODY_WITH_CHANNEL_ID, CHANNEL_ID), 84 | Arguments.of("ChannelDestroyed", BODY_WITH_CHANNEL_ID, CHANNEL_ID), 85 | Arguments.of("ChannelDtmfReceived", BODY_WITH_CHANNEL_ID, CHANNEL_ID), 86 | Arguments.of("ChannelHangupRequest", BODY_WITH_CHANNEL_ID, CHANNEL_ID), 87 | Arguments.of("ChannelHold", BODY_WITH_CHANNEL_ID, CHANNEL_ID), 88 | Arguments.of("ChannelStateChange", BODY_WITH_CHANNEL_ID, CHANNEL_ID), 89 | Arguments.of("ChannelTalkingFinished", BODY_WITH_CHANNEL_ID, CHANNEL_ID), 90 | Arguments.of("ChannelTalkingStarted", BODY_WITH_CHANNEL_ID, CHANNEL_ID), 91 | Arguments.of("ChannelUnhold", BODY_WITH_CHANNEL_ID, CHANNEL_ID), 92 | Arguments.of("Dial", BODY_WITH_PEER_ID, PEER_ID), 93 | Arguments.of("PlaybackContinuing", BODY_WITH_PLAYBACK_ID, PLAYBACK_ID), 94 | Arguments.of("PlaybackFinished", BODY_WITH_PLAYBACK_ID, PLAYBACK_ID), 95 | Arguments.of("PlaybackStarted", BODY_WITH_PLAYBACK_ID, PLAYBACK_ID), 96 | Arguments.of("RecordingFailed", BODY_WITH_RECORDING_NAME, RECORDING_NAME), 97 | Arguments.of("RecordingFinished", BODY_WITH_RECORDING_NAME, RECORDING_NAME), 98 | Arguments.of("RecordingStarted", BODY_WITH_RECORDING_NAME, RECORDING_NAME), 99 | Arguments.of("StasisEnd", BODY_WITH_CHANNEL_ID, CHANNEL_ID), 100 | Arguments.of("StasisStart", BODY_WITH_CHANNEL_ID, CHANNEL_ID)); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/boundary/events/StasisEvents.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.boundary.events; 2 | 3 | public class StasisEvents { 4 | 5 | public static final String invalidEvent = "not a vaild message"; 6 | 7 | public static final String recordingFinishedEvent = 8 | "{\n" 9 | + " \"recording\" : {\n" 10 | + " \"state\" : \"done\",\n" 11 | + " \"name\" : \"c0305a2b-4b8f-473a-a26e-a742d43ac032\",\n" 12 | + " \"duration\" : 13,\n" 13 | + " \"target_uri\" : \"channel:1536213799.2715\",\n" 14 | + " \"format\" : \"g722\"\n" 15 | + " },\n" 16 | + " \"asterisk_id\" : \"00:00:00:00:00:02\",\n" 17 | + " \"application\" : \"test-app\",\n" 18 | + " \"type\" : \"RecordingFinished\"\n" 19 | + "}\n"; 20 | 21 | public static final String unknownEvent = 22 | "{\n" 23 | + " \"type\" : \"Unknown\",\n" 24 | + " \"application\" : \"test-app\",\n" 25 | + " \"playback\" : {\n" 26 | + " \"id\" : \"072f6484-f781-405b-8c30-0a9a4496d14d\",\n" 27 | + " \"state\" : \"done\",\n" 28 | + " \"target_uri\" : \"channel:1532965104.0\",\n" 29 | + " \"media_uri\" : \"sound:hd/register_success\",\n" 30 | + " \"language\" : \"de\"\n" 31 | + " },\n" 32 | + " \"asterisk_id\" : \"00:00:00:00:00:01\"\n" 33 | + "}"; 34 | 35 | public static final String applicationReplacedEvent = 36 | "{\n" 37 | + " \"asterisk_id\" : \"00:00:00:00:00:01\",\n" 38 | + " \"application\" : \"test-app\",\n" 39 | + " \"type\" : \"ApplicationReplaced\"\n" 40 | + "}\n"; 41 | 42 | public static final String playbackFinishedEvent = 43 | "{\n" 44 | + " \"type\" : \"PlaybackFinished\",\n" 45 | + " \"application\" : \"test-app\",\n" 46 | + " \"playback\" : {\n" 47 | + " \"id\" : \"072f6484-f781-405b-8c30-0a9a4496d14d\",\n" 48 | + " \"state\" : \"done\",\n" 49 | + " \"target_uri\" : \"channel:1532965104.0\",\n" 50 | + " \"media_uri\" : \"sound:hd/register_success\",\n" 51 | + " \"language\" : \"de\"\n" 52 | + " },\n" 53 | + " \"asterisk_id\" : \"00:00:00:00:00:01\"\n" 54 | + "}"; 55 | 56 | public static final String stasisStartEvent = 57 | "{\n" 58 | + " \"channel\" : {\n" 59 | + " \"state\" : \"Ring\",\n" 60 | + " \"connected\" : {\n" 61 | + " \"number\" : \"\",\n" 62 | + " \"name\" : \"\"\n" 63 | + " },\n" 64 | + " \"language\" : \"en\",\n" 65 | + " \"id\" : \"1532965104.0\",\n" 66 | + " \"caller\" : {\n" 67 | + " \"number\" : \"callernumber\",\n" 68 | + " \"name\" : \"callername\"\n" 69 | + " },\n" 70 | + " \"accountcode\" : \"\",\n" 71 | + " \"dialplan\" : {\n" 72 | + " \"priority\" : 3,\n" 73 | + " \"exten\" : \"10000\",\n" 74 | + " \"context\" : \"default\"\n" 75 | + " },\n" 76 | + " \"name\" : \"PJSIP/proxy-00000000\",\n" 77 | + " \"creationtime\" : \"2018-07-30T17:38:24.433+0200\",\n" 78 | + " \"channelvars\" : {\n" 79 | + " \"CALL_CONTEXT\" : \"\"\n" 80 | + " }\n" 81 | + " },\n" 82 | + " \"asterisk_id\" : \"00:00:00:00:00:01\",\n" 83 | + " \"type\" : \"StasisStart\",\n" 84 | + " \"timestamp\" : \"2018-07-30T17:38:24.436+0200\",\n" 85 | + " \"args\" : [],\n" 86 | + " \"application\" : \"test-app\"\n" 87 | + "}"; 88 | 89 | public static final String stasisStartEventWithCallContext = 90 | "{\n" 91 | + " \"channel\" : {\n" 92 | + " \"state\" : \"Ring\",\n" 93 | + " \"connected\" : {\n" 94 | + " \"number\" : \"\",\n" 95 | + " \"name\" : \"\"\n" 96 | + " },\n" 97 | + " \"language\" : \"en\",\n" 98 | + " \"id\" : \"1532965104.0\",\n" 99 | + " \"caller\" : {\n" 100 | + " \"number\" : \"callernumber\",\n" 101 | + " \"name\" : \"callername\"\n" 102 | + " },\n" 103 | + " \"accountcode\" : \"\",\n" 104 | + " \"dialplan\" : {\n" 105 | + " \"priority\" : 3,\n" 106 | + " \"exten\" : \"10000\",\n" 107 | + " \"context\" : \"default\"\n" 108 | + " },\n" 109 | + " \"name\" : \"PJSIP/proxy-00000000\",\n" 110 | + " \"creationtime\" : \"2018-07-30T17:38:24.433+0200\",\n" 111 | + " \"channelvars\" : {\n" 112 | + " \"CALL_CONTEXT\" : \"aCallContext\"\n" 113 | + " }\n" 114 | + " },\n" 115 | + " \"asterisk_id\" : \"00:00:00:00:00:01\",\n" 116 | + " \"type\" : \"StasisStart\",\n" 117 | + " \"timestamp\" : \"2018-07-30T17:38:24.436+0200\",\n" 118 | + " \"args\" : [],\n" 119 | + " \"application\" : \"test-app\"\n" 120 | + "}"; 121 | } 122 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/health/KafkaConnectionCheckTest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.health; 2 | 3 | import static org.apache.kafka.clients.CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG; 4 | import static org.hamcrest.CoreMatchers.is; 5 | import static org.hamcrest.MatcherAssert.assertThat; 6 | 7 | import akka.actor.testkit.typed.javadsl.ActorTestKit; 8 | import akka.actor.testkit.typed.javadsl.TestProbe; 9 | import com.typesafe.config.Config; 10 | import com.typesafe.config.ConfigFactory; 11 | import com.typesafe.config.ConfigValueFactory; 12 | import com.typesafe.config.impl.ConfigImpl; 13 | import io.retel.ariproxy.health.KafkaConnectionCheck.ReportKafkaConnectionHealth; 14 | import io.retel.ariproxy.health.api.HealthReport; 15 | import java.util.List; 16 | import java.util.Map; 17 | import org.apache.kafka.clients.admin.AdminClient; 18 | import org.apache.kafka.clients.admin.NewTopic; 19 | import org.junit.jupiter.api.AfterAll; 20 | import org.junit.jupiter.api.BeforeAll; 21 | import org.junit.jupiter.api.Test; 22 | import org.testcontainers.containers.KafkaContainer; 23 | import org.testcontainers.junit.jupiter.Testcontainers; 24 | import org.testcontainers.utility.DockerImageName; 25 | 26 | @Testcontainers 27 | class KafkaConnectionCheckTest { 28 | 29 | private static final ActorTestKit testKit = 30 | ActorTestKit.create("testKit", ConfigFactory.defaultApplication()); 31 | 32 | private static final KafkaContainer kafkaContainer = 33 | new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1")); 34 | 35 | public static final String COMMANDS_TOPIC = "commands-topic"; 36 | public static final String EVENTS_AND_RESPONSES_TOPIC = "events-and-responses-topic"; 37 | 38 | @BeforeAll 39 | public static void beforeAll() { 40 | kafkaContainer.start(); 41 | try (var admin = 42 | AdminClient.create( 43 | Map.of(BOOTSTRAP_SERVERS_CONFIG, kafkaContainer.getBootstrapServers()))) { 44 | admin.createTopics( 45 | List.of( 46 | new NewTopic(COMMANDS_TOPIC, 1, (short) 1), 47 | new NewTopic(EVENTS_AND_RESPONSES_TOPIC, 1, (short) 1))); 48 | } 49 | } 50 | 51 | @AfterAll 52 | public static void cleanup() { 53 | testKit.shutdownTestKit(); 54 | } 55 | 56 | @Test 57 | void provideOkHealthReport() { 58 | final Config testConfig = 59 | ConfigImpl.emptyConfig("KafkaConnectionCheckTest") 60 | .withValue( 61 | KafkaConnectionCheck.BOOTSTRAP_SERVERS, 62 | ConfigValueFactory.fromAnyRef(kafkaContainer.getBootstrapServers())) 63 | .withValue( 64 | "security", 65 | ConfigValueFactory.fromAnyRef( 66 | Map.of( 67 | "protocol", "PLAIN", 68 | "user", "", 69 | "password", ""))) 70 | .withValue( 71 | KafkaConnectionCheck.CONSUMER_GROUP, 72 | ConfigValueFactory.fromAnyRef("my-test-consumer-group")) 73 | .withValue( 74 | KafkaConnectionCheck.COMMANDS_TOPIC, ConfigValueFactory.fromAnyRef(COMMANDS_TOPIC)) 75 | .withValue( 76 | KafkaConnectionCheck.EVENTS_AND_RESPONSES_TOPIC, 77 | ConfigValueFactory.fromAnyRef(EVENTS_AND_RESPONSES_TOPIC)); 78 | final TestProbe healthReportProbe = testKit.createTestProbe(); 79 | final akka.actor.typed.ActorRef connectionCheck = 80 | testKit.spawn(KafkaConnectionCheck.create(testConfig)); 81 | 82 | connectionCheck.tell(new ReportKafkaConnectionHealth(healthReportProbe.ref())); 83 | 84 | healthReportProbe.expectMessage(HealthReport.ok()); 85 | } 86 | 87 | @Test 88 | void provideNotOkHealthReport() { 89 | final Config testConfig = 90 | ConfigImpl.emptyConfig("KafkaConnectionCheckTest") 91 | .withValue( 92 | KafkaConnectionCheck.BOOTSTRAP_SERVERS, 93 | ConfigValueFactory.fromAnyRef(kafkaContainer.getBootstrapServers())) 94 | .withValue( 95 | "security", 96 | ConfigValueFactory.fromAnyRef( 97 | Map.of( 98 | "protocol", "PLAIN", 99 | "user", "", 100 | "password", ""))) 101 | .withValue( 102 | KafkaConnectionCheck.CONSUMER_GROUP, 103 | ConfigValueFactory.fromAnyRef("my-test-consumer-group")) 104 | .withValue( 105 | KafkaConnectionCheck.COMMANDS_TOPIC, 106 | ConfigValueFactory.fromAnyRef("some-non-existing-topic")) 107 | .withValue( 108 | KafkaConnectionCheck.EVENTS_AND_RESPONSES_TOPIC, 109 | ConfigValueFactory.fromAnyRef(EVENTS_AND_RESPONSES_TOPIC)); 110 | 111 | final TestProbe healthReportProbe = testKit.createTestProbe(); 112 | final akka.actor.typed.ActorRef connectionCheck = 113 | testKit.spawn(KafkaConnectionCheck.create(testConfig)); 114 | 115 | connectionCheck.tell(new ReportKafkaConnectionHealth(healthReportProbe.ref())); 116 | 117 | final HealthReport report = healthReportProbe.expectMessageClass(HealthReport.class); 118 | assertThat(report.errors().size(), is(1)); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/health/api/HealthReportTest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.health.api; 2 | 3 | import static org.hamcrest.CoreMatchers.is; 4 | import static org.hamcrest.CoreMatchers.not; 5 | import static org.hamcrest.MatcherAssert.assertThat; 6 | 7 | import io.vavr.collection.List; 8 | import org.junit.jupiter.api.Test; 9 | 10 | class HealthReportTest { 11 | 12 | @Test 13 | void mergeReturnSameInstanceIfOtherIsNull() { 14 | HealthReport report = HealthReport.ok(); 15 | HealthReport mergedReport = report.merge(null); 16 | assertThat(mergedReport, is(report)); 17 | } 18 | 19 | @Test 20 | void mergeReturnOtherInstanceIfIsNotNull() { 21 | HealthReport report = HealthReport.ok(); 22 | HealthReport mergedReport = report.merge(HealthReport.error("Some Error")); 23 | assertThat(mergedReport, is(not(report))); 24 | assertThat(mergedReport.errors(), is(List.of("Some Error"))); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/health/api/HealthResponseTest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.health.api; 2 | 3 | import static org.hamcrest.CoreMatchers.is; 4 | import static org.hamcrest.MatcherAssert.assertThat; 5 | 6 | import io.vavr.collection.List; 7 | import java.util.ArrayList; 8 | import org.junit.jupiter.api.Test; 9 | 10 | class HealthResponseTest { 11 | 12 | @Test 13 | void ensureEmptyListGeneratesIsOk() { 14 | HealthResponse response = HealthResponse.fromErrors(new ArrayList<>()); 15 | assertThat(response.isOk(), is(true)); 16 | assertThat(response.errors(), is(new ArrayList())); 17 | } 18 | 19 | @Test 20 | void ensureFullListGeneratesIsNotOk() { 21 | HealthResponse response = HealthResponse.fromErrors(List.of("Error Message").asJava()); 22 | assertThat(response.isOk(), is(false)); 23 | assertThat(response.errors(), is(List.of("Error Message").asJava())); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/persistence/plugin/CassandraPersistenceStoreTest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.persistence.plugin; 2 | 3 | import static io.vavr.API.None; 4 | import static io.vavr.API.Some; 5 | import static org.hamcrest.CoreMatchers.is; 6 | import static org.hamcrest.MatcherAssert.assertThat; 7 | 8 | import com.datastax.oss.driver.api.core.CqlSession; 9 | import java.io.IOException; 10 | import org.cassandraunit.CQLDataLoader; 11 | import org.cassandraunit.dataset.cql.ClassPathCQLDataSet; 12 | import org.cassandraunit.utils.EmbeddedCassandraServerHelper; 13 | import org.junit.jupiter.api.AfterEach; 14 | import org.junit.jupiter.api.BeforeEach; 15 | import org.junit.jupiter.api.Disabled; 16 | import org.junit.jupiter.api.Test; 17 | 18 | @Disabled 19 | class CassandraPersistenceStoreTest { 20 | 21 | private static final String THE_KEY = "key"; 22 | private static final String THE_VALUE = "value"; 23 | private CqlSession cqlSession; 24 | 25 | @BeforeEach 26 | public void setup() throws IOException, InterruptedException { 27 | EmbeddedCassandraServerHelper.startEmbeddedCassandra(); 28 | cqlSession = EmbeddedCassandraServerHelper.getSession(); 29 | new CQLDataLoader(cqlSession).load(new ClassPathCQLDataSet("persistence/cassandra.cql")); 30 | } 31 | 32 | @AfterEach 33 | public void teardown() { 34 | cqlSession.close(); 35 | } 36 | 37 | @Test 38 | public void testReadWriteReal() { 39 | final CassandraPersistenceStore store = new CassandraPersistenceStore(cqlSession); 40 | 41 | assertThat(store.get(THE_KEY).get(), is(None())); 42 | 43 | assertThat(store.set(THE_KEY, THE_VALUE).await().isSuccess(), is(true)); 44 | 45 | assertThat(store.get(THE_KEY).get(), is(Some(THE_VALUE))); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/persistence/plugin/InMemoryPersistenceStore.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.persistence.plugin; 2 | 3 | import io.retel.ariproxy.persistence.PersistenceStore; 4 | import io.vavr.collection.HashMap; 5 | import io.vavr.collection.Map; 6 | import io.vavr.concurrent.Future; 7 | import io.vavr.control.Option; 8 | 9 | public class InMemoryPersistenceStore implements PersistenceStore { 10 | 11 | private Map store = HashMap.empty(); 12 | 13 | @Override 14 | public Future set(String key, String value) { 15 | if (key.contains("failure")) { 16 | return Future.failed(new Exception("Failed to set value for key")); 17 | } 18 | store = store.put(key, value); 19 | return Future.successful(value); 20 | } 21 | 22 | @Override 23 | public Future> get(String key) { 24 | return Future.successful(store.get(key)); 25 | } 26 | 27 | @Override 28 | public void shutdown() {} 29 | 30 | public static InMemoryPersistenceStore create() { 31 | return new InMemoryPersistenceStore(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/persistence/plugin/RedisPersistenceStoreTest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.persistence.plugin; 2 | 3 | import static io.vavr.API.None; 4 | import static org.hamcrest.CoreMatchers.is; 5 | import static org.hamcrest.MatcherAssert.assertThat; 6 | import static org.mockito.ArgumentMatchers.*; 7 | import static org.mockito.Mockito.*; 8 | 9 | import io.lettuce.core.RedisClient; 10 | import io.lettuce.core.SetArgs; 11 | import io.lettuce.core.api.StatefulRedisConnection; 12 | import io.lettuce.core.api.sync.RedisCommands; 13 | import io.vavr.control.Option; 14 | import java.time.Duration; 15 | import org.junit.jupiter.api.Test; 16 | 17 | class RedisPersistenceStoreTest { 18 | 19 | private static final String theValue = "value"; 20 | private static final String theKey = "key"; 21 | 22 | @Test 23 | void getShouldBePassedToUnderlyingRedisClient() { 24 | RedisClient redisClient = mock(RedisClient.class); 25 | StatefulRedisConnection connection = mock(StatefulRedisConnection.class); 26 | RedisCommands commands = mock(RedisCommands.class); 27 | when(redisClient.connect()).thenReturn(connection); 28 | when(connection.sync()).thenReturn(commands); 29 | when(commands.get(anyString())).thenReturn(theValue); 30 | 31 | RedisPersistenceStore store = RedisPersistenceStore.create(redisClient, Option.none()); 32 | 33 | assertThat(store.get(theKey).await().get().get(), is(theValue)); 34 | } 35 | 36 | @Test 37 | void getShouldBePassedToUnderlyingRedisClientAndReturnNone() { 38 | RedisClient redisClient = mock(RedisClient.class); 39 | StatefulRedisConnection connection = mock(StatefulRedisConnection.class); 40 | RedisCommands commands = mock(RedisCommands.class); 41 | when(redisClient.connect()).thenReturn(connection); 42 | when(connection.sync()).thenReturn(commands); 43 | when(commands.get(anyString())).thenReturn(null); 44 | 45 | RedisPersistenceStore store = RedisPersistenceStore.create(redisClient, Option.none()); 46 | 47 | assertThat(store.get(theKey).await().get(), is(None())); 48 | } 49 | 50 | @Test 51 | void setShouldBePassedToUnderlyingRedisClient() { 52 | RedisClient redisClient = mock(RedisClient.class); 53 | StatefulRedisConnection connection = mock(StatefulRedisConnection.class); 54 | RedisCommands commands = mock(RedisCommands.class); 55 | when(redisClient.connect()).thenReturn(connection); 56 | when(connection.sync()).thenReturn(commands); 57 | when(commands.set(anyString(), anyString(), any(SetArgs.class))).thenReturn(theValue); 58 | when(commands.expire(anyString(), any(Duration.class))).thenReturn(true); 59 | 60 | RedisPersistenceStore store = RedisPersistenceStore.create(redisClient, Option.none()); 61 | 62 | assertThat(store.set(theKey, theValue).await().get(), is(theValue)); 63 | verify(commands, never()).expire(anyString(), any(Duration.class)); 64 | } 65 | 66 | @Test 67 | void setAndExpireShouldBePassedToUnderlyingRedisClient() { 68 | RedisClient redisClient = mock(RedisClient.class); 69 | StatefulRedisConnection connection = mock(StatefulRedisConnection.class); 70 | RedisCommands commands = mock(RedisCommands.class); 71 | when(redisClient.connect()).thenReturn(connection); 72 | when(connection.sync()).thenReturn(commands); 73 | when(commands.set(anyString(), anyString(), any(SetArgs.class))).thenReturn(theValue); 74 | when(commands.expire(anyString(), any(Duration.class))).thenReturn(true); 75 | 76 | RedisPersistenceStore store = 77 | RedisPersistenceStore.create(redisClient, Option.some(Duration.ofSeconds(1))); 78 | 79 | assertThat(store.set(theKey, theValue).await().get(), is(theValue)); 80 | 81 | verify(commands).expire(anyString(), eq(Duration.ofSeconds(1))); 82 | } 83 | 84 | @Test 85 | void shutdown() { 86 | RedisClient redisClient = mock(RedisClient.class); 87 | RedisPersistenceStore store = RedisPersistenceStore.create(redisClient, Option.none()); 88 | 89 | store.shutdown(); 90 | 91 | verify(redisClient, times(1)).shutdown(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/test/java/io/retel/ariproxy/persistence/plugin/SQLitePersistenceStoreTest.java: -------------------------------------------------------------------------------- 1 | package io.retel.ariproxy.persistence.plugin; 2 | 3 | import static io.vavr.API.None; 4 | import static io.vavr.API.Some; 5 | import static org.awaitility.Awaitility.await; 6 | import static org.hamcrest.CoreMatchers.is; 7 | import static org.hamcrest.MatcherAssert.assertThat; 8 | 9 | import java.time.Duration; 10 | import java.util.UUID; 11 | import org.junit.jupiter.api.AfterEach; 12 | import org.junit.jupiter.api.BeforeEach; 13 | import org.junit.jupiter.api.Test; 14 | 15 | class SQLitePersistenceStoreTest { 16 | 17 | private SQLitePersistenceStore store; 18 | 19 | @BeforeEach 20 | void setup() { 21 | store = SQLitePersistenceStore.create(); 22 | } 23 | 24 | @AfterEach 25 | void teardown() { 26 | store.shutdown(); 27 | } 28 | 29 | @Test 30 | void testReadWriteReal() { 31 | assertThat(store.get("key").get(), is(None())); 32 | 33 | assertThat(store.set("key", "value").await().isSuccess(), is(true)); 34 | 35 | assertThat(store.get("key").get(), is(Some("value"))); 36 | } 37 | 38 | @Test 39 | void canUpdateValueForSameKey() { 40 | assertThat(store.get("updateKey").get(), is(None())); 41 | 42 | assertThat(store.set("updateKey", "value1").await().isSuccess(), is(true)); 43 | assertThat(store.set("updateKey", "value2").await().isSuccess(), is(true)); 44 | 45 | assertThat(store.get("updateKey").get(), is(Some("value2"))); 46 | } 47 | 48 | @Test 49 | void shouldCleanupOldEntries() { 50 | assertThat(store.get("cleanupKey").get(), is(None())); 51 | assertThat(store.set("cleanupKey", "value").await().isSuccess(), is(true)); 52 | assertThat(store.get("cleanupKey").get(), is(Some("value"))); 53 | 54 | await() 55 | .atMost(Duration.ofSeconds(4)) 56 | .until( 57 | () -> { 58 | store.set(UUID.randomUUID().toString(), "value"); 59 | return store.get("cleanupKey").get().isEmpty(); 60 | }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/test/resources/application.conf: -------------------------------------------------------------------------------- 1 | akka { 2 | loggers = ["akka.event.slf4j.Slf4jLogger"] 3 | loglevel = "DEBUG" 4 | stdout-loglevel = "DEBUG" 5 | logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" 6 | event-handlers = ["akka.event.slf4j.Slf4jEventHandler"] 7 | log-dead-letters-during-shutdown = false 8 | 9 | http { 10 | client { 11 | user-agent-header = ari-proxy 12 | idle-timeout = 60s # the default 13 | 14 | websocket { 15 | periodic-keep-alive-mode = ping 16 | periodic-keep-alive-max-idle = 10s 17 | } 18 | } 19 | } 20 | } 21 | 22 | service { 23 | name = "test-proxy" 24 | stasis-app = "test-app" 25 | websocket-uri = "ws://localhost:8088/ari/events?app=test-app&api_key=asterisk:asterisk" 26 | httpport = 9000 27 | 28 | rest { 29 | user = "asterisk" 30 | password = "asterisk" 31 | uri = "http://localhost:8088" 32 | } 33 | 34 | kafka { 35 | bootstrap-servers = "localhost:9092" 36 | consumer-group = "consumerGroup" 37 | commands-topic = "commandsTopic" 38 | events-and-responses-topic = "eventsAndResponsesTopic" 39 | } 40 | 41 | sqlite { 42 | url = "jdbc:sqlite::memory:" 43 | ttl = 1s 44 | } 45 | 46 | redis { 47 | ttl = 1s 48 | } 49 | 50 | persistence-store = "io.retel.ariproxy.persistence.plugin.InMemoryPersistenceStore" 51 | 52 | metrics { 53 | healthReportTimeout = 100ms 54 | measurement-names { 55 | backing-service-availability = "backing_service.availability" 56 | } 57 | common-tags { 58 | hostname = "localhost" 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/test/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %highlight{%d{yyyy-MM-dd/HH:mm:ss,SSS/zzz}{CET} [%p] [%t] %c{1} - %msg%n%throwable} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/test/resources/messages/ari/responses/bridgeCreateResponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "BRIDGE_ID", 3 | "name": "BRIDGE_NAME", 4 | "technology": "simple_bridge", 5 | "bridge_type": "mixing", 6 | "bridge_class": "stasis", 7 | "creator": "Stasis", 8 | "channels": [], 9 | "creationtime": "2020-12-01T09:59:10.289+0100", 10 | "video_mode": "talker" 11 | } 12 | -------------------------------------------------------------------------------- /src/test/resources/messages/commands/bridgeCreateCommandWithBody.json: -------------------------------------------------------------------------------- 1 | { 2 | "callContext": "CALL_CONTEXT", 3 | "commandId": "COMMAND_ID", 4 | "ariCommand": { 5 | "url": "/bridges", 6 | "body": { 7 | "bridgeId": "BRIDGE_ID", 8 | "name": "BRIDGE_NAME" 9 | }, 10 | "method": "POST" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/test/resources/messages/commands/channelAnswerCommand.json: -------------------------------------------------------------------------------- 1 | { 2 | "callContext": "CALL_CONTEXT", 3 | "commandId": "COMMANDID", 4 | "ariCommand": { 5 | "url": "/channels/1533218784.36/answer", 6 | "method": "POST" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/test/resources/messages/commands/channelAnswerCommandWithoutCommandId.json: -------------------------------------------------------------------------------- 1 | { 2 | "callContext": "CALL_CONTEXT", 3 | "ariCommand": { 4 | "url": "/channels/1533218784.36/answer", 5 | "method": "POST" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/test/resources/messages/commands/channelDeleteWithReasonCommand.json: -------------------------------------------------------------------------------- 1 | { 2 | "callContext": "CALL_CONTEXT", 3 | "commandId": "COMMANDID", 4 | "ariCommand": { 5 | "url": "/channels/1533218784.36?reason=normal", 6 | "method": "DELETE" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/test/resources/messages/commands/channelPlaybackCommand.json: -------------------------------------------------------------------------------- 1 | { 2 | "callContext": "CALL_CONTEXT", 3 | "commandId": "COMMANDID", 4 | "ariCommand": { 5 | "url": "/channels/1533286879.42/play/c4958563-1ba4-4f2f-a60f-626a624bf0e6", 6 | "method": "POST", 7 | "body": { 8 | "media": "sound:hd/register_success", 9 | "lang": "de" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/test/resources/messages/events/stasisStartEventWithCallContext.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "STASIS_START", 3 | "commandsTopic": "commandsTopic", 4 | "payload": { 5 | "channel": { 6 | "state": "Ring", 7 | "connected": { 8 | "number": "", 9 | "name": "" 10 | }, 11 | "language": "en", 12 | "id": "1532965104.0", 13 | "caller": { 14 | "number": "callernumber", 15 | "name": "callername" 16 | }, 17 | "accountcode": "", 18 | "dialplan": { 19 | "priority": 3, 20 | "exten": "10000", 21 | "context": "default" 22 | }, 23 | "name": "PJSIP/proxy-00000000", 24 | "creationtime": "2018-07-30T17:38:24.433+0200", 25 | "channelvars":{ 26 | "CALL_CONTEXT": "aCallContext" 27 | } 28 | }, 29 | "asterisk_id": "00:00:00:00:00:01", 30 | "type": "StasisStart", 31 | "timestamp": "2018-07-30T17:38:24.436+0200", 32 | "args": [], 33 | "application": "test-app" 34 | }, 35 | "callContext": "CALL_CONTEXT_PROVIDED", 36 | "resources": [ 37 | { 38 | "type": "CHANNEL", 39 | "id": "1532965104.0" 40 | } 41 | ], 42 | "commandId": null, 43 | "commandRequest": null 44 | } 45 | -------------------------------------------------------------------------------- /src/test/resources/messages/events/stasisStartEventWithoutCallContext.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "STASIS_START", 3 | "commandsTopic": "commandsTopic", 4 | "payload": { 5 | "channel": { 6 | "state": "Ring", 7 | "connected": { 8 | "number": "", 9 | "name": "" 10 | }, 11 | "language": "en", 12 | "id": "1532965104.0", 13 | "caller": { 14 | "number": "callernumber", 15 | "name": "callername" 16 | }, 17 | "accountcode": "", 18 | "dialplan": { 19 | "priority": 3, 20 | "exten": "10000", 21 | "context": "default" 22 | }, 23 | "name": "PJSIP/proxy-00000000", 24 | "creationtime": "2018-07-30T17:38:24.433+0200", 25 | "channelvars":{ 26 | "CALL_CONTEXT": "" 27 | } 28 | }, 29 | "asterisk_id": "00:00:00:00:00:01", 30 | "type": "StasisStart", 31 | "timestamp": "2018-07-30T17:38:24.436+0200", 32 | "args": [], 33 | "application": "test-app" 34 | }, 35 | "callContext": "CALL_CONTEXT_PROVIDED", 36 | "resources": [ 37 | { 38 | "type": "CHANNEL", 39 | "id": "1532965104.0" 40 | } 41 | ], 42 | "commandId": null, 43 | "commandRequest": null 44 | } 45 | -------------------------------------------------------------------------------- /src/test/resources/messages/responses/bridgeCreateRequestFailedResponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "RESPONSE", 3 | "commandsTopic": "commandsTopic", 4 | "payload": { 5 | "status_code": 500 6 | }, 7 | "callContext": "CALL_CONTEXT", 8 | "resources": [ 9 | { 10 | "type": "BRIDGE", 11 | "id": "BRIDGE_ID" 12 | } 13 | ], 14 | "commandId": "COMMAND_ID", 15 | "commandRequest": { 16 | "method": "POST", 17 | "url": "/bridges" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/test/resources/messages/responses/bridgeCreateResponseWithBody.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "RESPONSE", 3 | "commandsTopic": "commandsTopic", 4 | "payload": { 5 | "body": { 6 | "id": "BRIDGE_ID", 7 | "name": "BRIDGE_NAME", 8 | "technology": "simple_bridge", 9 | "bridge_type": "mixing", 10 | "bridge_class": "stasis", 11 | "creator": "Stasis", 12 | "channels": [], 13 | "creationtime": "2020-12-01T09:59:10.289+0100", 14 | "video_mode": "talker" 15 | }, 16 | "status_code": 200 17 | }, 18 | "callContext": "CALL_CONTEXT", 19 | "resources": [ 20 | { 21 | "type": "BRIDGE", 22 | "id": "BRIDGE_ID" 23 | } 24 | ], 25 | "commandId": "COMMAND_ID", 26 | "commandRequest": { 27 | "method": "POST", 28 | "url": "/bridges" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/test/resources/messages/responses/channelAnswerResponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "RESPONSE", 3 | "commandsTopic": "commandsTopic", 4 | "payload": { 5 | "status_code": 204 6 | }, 7 | "callContext": "CALL_CONTEXT", 8 | "commandId": "COMMANDID", 9 | "commandRequest": { 10 | "method": "POST", 11 | "url": "/channels/1533218784.36/answer" 12 | }, 13 | "resources": [ 14 | { 15 | "type": "CHANNEL", 16 | "id": "1533218784.36" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/test/resources/messages/responses/channelAnswerResponseWithoutCommandId.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "RESPONSE", 3 | "commandsTopic": "commandsTopic", 4 | "payload": { 5 | "status_code": 204 6 | }, 7 | "callContext": "CALL_CONTEXT", 8 | "commandRequest": { 9 | "method": "POST", 10 | "url": "/channels/1533218784.36/answer" 11 | }, 12 | "resources": [ 13 | { 14 | "type": "CHANNEL", 15 | "id": "1533218784.36" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/test/resources/messages/responses/channelDeleteWithReasonResponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "RESPONSE", 3 | "commandsTopic": "commandsTopic", 4 | "payload": { 5 | "status_code": 204 6 | }, 7 | "callContext": "CALL_CONTEXT", 8 | "commandId": "COMMANDID", 9 | "commandRequest": { 10 | "method": "DELETE", 11 | "url": "/channels/1533218784.36?reason=normal" 12 | }, 13 | "resources": [ 14 | { 15 | "type": "CHANNEL", 16 | "id": "1533218784.36" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/test/resources/messages/responses/channelPlaybackResponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "RESPONSE", 3 | "commandsTopic": "commandsTopic", 4 | "payload": { 5 | "body": { 6 | "key": "value" 7 | }, 8 | "status_code": 200 9 | }, 10 | "callContext": "CALL_CONTEXT", 11 | "commandId": "COMMANDID", 12 | "commandRequest": { 13 | "method": "POST", 14 | "url": "/channels/1533286879.42/play/c4958563-1ba4-4f2f-a60f-626a624bf0e6" 15 | }, 16 | "resources": [ 17 | { 18 | "type": "PLAYBACK", 19 | "id": "c4958563-1ba4-4f2f-a60f-626a624bf0e6" 20 | }, 21 | { 22 | "type": "CHANNEL", 23 | "id": "1533286879.42" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/test/resources/no_optionals.conf: -------------------------------------------------------------------------------- 1 | service { 2 | stasis-app = "test-app" 3 | websocket-uri = "ws://localhost:8088" 4 | 5 | rest { 6 | uri = "http://localhost:8088" 7 | } 8 | 9 | kafka { 10 | bootstrap-servers = "localhost:9092" 11 | commands-topic = "commandsTopic" 12 | events-and-responses-topic = "eventsAndResponsesTopic" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/test/resources/persistence/cassandra.cql: -------------------------------------------------------------------------------- 1 | CREATE KEYSPACE retel with replication = {'class':'SimpleStrategy','replication_factor':1}; 2 | 3 | USE retel; 4 | 5 | CREATE TABLE retel ( 6 | "key" text primary key, 7 | "value" text 8 | ); 9 | 10 | --------------------------------------------------------------------------------