├── .github └── workflows │ ├── ci.yml │ └── clean.yml ├── .gitignore ├── .gitpod.Dockerfile ├── .gitpod.yml ├── .mergify.yml ├── LICENSE ├── Procfile ├── README.md ├── TUTORIAL.md ├── app.json ├── build.sbt ├── project ├── build.properties └── plugins.sbt └── src ├── main ├── resources │ └── application.conf └── scala │ └── PekkoHttpMicroservice.scala └── test └── scala └── ServiceSpec.scala /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: ['master'] 6 | pull_request: 7 | branches: ['*'] 8 | env: 9 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 10 | 11 | jobs: 12 | build: 13 | name: Build and Test 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest] 17 | scala: [3.4.1] 18 | java: [adopt@1.8] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - name: Checkout current branch (full) 22 | uses: actions/checkout@v2 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Setup Java and Scala 27 | uses: olafurpg/setup-scala@v10 28 | with: 29 | java-version: ${{ matrix.java }} 30 | 31 | - name: Cache sbt 32 | uses: actions/cache@v2 33 | with: 34 | path: | 35 | ~/.sbt 36 | ~/.ivy2/cache 37 | ~/.coursier/cache/v1 38 | ~/.cache/coursier/v1 39 | ~/AppData/Local/Coursier/Cache/v1 40 | ~/Library/Caches/Coursier/v1 41 | key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 42 | 43 | - name: Build project 44 | run: sbt ++${{ matrix.scala }} test 45 | -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | name: Clean 2 | 3 | on: push 4 | 5 | jobs: 6 | delete-artifacts: 7 | name: Delete Artifacts 8 | runs-on: ubuntu-latest 9 | env: 10 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 11 | steps: 12 | - name: Delete artifacts 13 | run: | 14 | # Customize those three lines with your repository and credentials: 15 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 16 | # A shortcut to call GitHub API. 17 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 18 | # A temporary file which receives HTTP response headers. 19 | TMPFILE=/tmp/tmp.$$ 20 | # An associative array, key: artifact name, value: number of artifacts of that name. 21 | declare -A ARTCOUNT 22 | # Process all artifacts on this repository, loop on returned "pages". 23 | URL=$REPO/actions/artifacts 24 | while [[ -n "$URL" ]]; do 25 | # Get current page, get response headers in a temporary file. 26 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 27 | # Get URL of next page. Will be empty if we are at the last page. 28 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 29 | rm -f $TMPFILE 30 | # Number of artifacts on this page: 31 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 32 | # Loop on all artifacts on this page. 33 | for ((i=0; $i < $COUNT; i++)); do 34 | # Get name of artifact and count instances of this name. 35 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 36 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 37 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 38 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 39 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 40 | ghapi -X DELETE $REPO/actions/artifacts/$id 41 | done 42 | done -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | 4 | # sbt specific 5 | .cache 6 | .history 7 | .lib/ 8 | .bsp/ 9 | dist/* 10 | target/ 11 | lib_managed/ 12 | src_managed/ 13 | project/boot/ 14 | project/plugins/project/ 15 | 16 | # Scala-IDE specific 17 | .scala_dependencies 18 | .worksheet 19 | 20 | # IntelliJ specific 21 | .idea 22 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full 2 | 3 | RUN echo 'unset JAVA_TOOL_OPTIONS' >> /home/gitpod/.bashrc.d/99-clear-java-tool-options && rm -rf /home/gitpod/.sdkman 4 | 5 | RUN curl -fLo cs https://git.io/coursier-cli-linux &&\ 6 | chmod +x cs &&\ 7 | ./cs java --jvm adopt:1.8.0-292 --env >> /home/gitpod/.bashrc.d/90-cs &&\ 8 | ./cs install --env >> /home/gitpod/.bashrc.d/90-cs &&\ 9 | ./cs install \ 10 | bloop \ 11 | sbt &&\ 12 | rm -f cs 13 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: 2 | file: .gitpod.Dockerfile 3 | vscode: 4 | extensions: 5 | - scala-lang.scala 6 | - scalameta.metals 7 | ports: 8 | - port: 9000 9 | onOpen: ignore 10 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: assign and label scala-steward's PRs 3 | conditions: 4 | - author=scala-steward 5 | actions: 6 | assign: 7 | users: [luksow] 8 | label: 9 | add: [dependency-update] 10 | - name: merge Scala Steward's PRs 11 | conditions: 12 | - base=master 13 | - author=scala-steward 14 | - status-success=Build and Test (ubuntu-latest, 3.0.1, adopt@1.8) 15 | actions: 16 | merge: 17 | method: squash 18 | strict: true -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Iterators 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: target/universal/stage/bin/akka-http-microservice -Dhttp.port=$PORT 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pekko HTTP microservice example 2 | 3 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/theiterators/pekko-http-microservice/master/COPYING) 4 | ![Build Status](https://github.com/theiterators/pekko-http-microservice/actions/workflows/ci.yml/badge.svg) 5 | 6 | **This repository is a fork of [akka-http-microservice](https://github.com/theiterators/akka-http-microservice).** 7 | 8 | This project demonstrates the [Pekko HTTP](https://pekko.apache.org/docs/pekko/current///index.html) library and Scala to write a simple REST (micro)service. The project shows the following tasks that are typical for most Pekko HTTP-based projects: 9 | 10 | * starting standalone HTTP server, 11 | * handling file-based configuration, 12 | * logging, 13 | * routing, 14 | * deconstructing requests, 15 | * unmarshalling JSON entities to Scala's case classes, 16 | * marshaling Scala's case classes to JSON responses, 17 | * error handling, 18 | * issuing requests to external services, 19 | * testing with mocking of external services. 20 | 21 | The service in the template provides two REST endpoints - one which gives GeoIP info for given IP and another for calculating geographical distance between given pair of IPs. The project uses the service [ip-api](http://ip-api.com/) which offers JSON IP and GeoIP REST API for free for non-commercial use. 22 | 23 | If you want to read a more thorough explanation, check out [tutorial](https://github.com/theiterators/pekko-http-microservice/blob/master/TUTORIAL.md). 24 | 25 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy) 26 | [![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/theiterators/pekko-http-microservice) 27 | 28 | ## Usage 29 | 30 | Start services with sbt: 31 | 32 | ``` 33 | $ sbt 34 | > ~reStart 35 | ``` 36 | 37 | With the service up, you can start sending HTTP requests: 38 | 39 | ``` 40 | $ curl http://localhost:9000/ip/8.8.8.8 41 | { 42 | "city": "Mountain View", 43 | "query": "8.8.8.8", 44 | "country": "United States", 45 | "lon": -122.0881, 46 | "lat": 37.3845 47 | } 48 | ``` 49 | 50 | ``` 51 | $ curl -X POST -H 'Content-Type: application/json' http://localhost:9000/ip -d '{"ip1": "8.8.8.8", "ip2": "93.184.216.34"}' 52 | { 53 | "distance": 4347.624347494718, 54 | "ip1Info": { 55 | "city": "Mountain View", 56 | "query": "8.8.8.8", 57 | "country": "United States", 58 | "lon": -122.0881, 59 | "lat": 37.3845 60 | }, 61 | "ip2Info": { 62 | "city": "Norwell", 63 | "query": "93.184.216.34", 64 | "country": "United States", 65 | "lon": -70.8228, 66 | "lat": 42.1508 67 | } 68 | } 69 | ``` 70 | 71 | ### Testing 72 | 73 | Execute tests using `test` command: 74 | 75 | ``` 76 | $ sbt 77 | > test 78 | ``` 79 | 80 | ## Author & license 81 | 82 | If you have any questions regarding this project, contact: 83 | 84 | Łukasz Sowa from [Iterators](https://www.iteratorshq.com). 85 | 86 | For licensing info see LICENSE file in project's root directory. 87 | -------------------------------------------------------------------------------- /TUTORIAL.md: -------------------------------------------------------------------------------- 1 | # Pekko HTTP microservice 2 | 3 | This template lets you learn about: 4 | 5 | * starting standalone HTTP server, 6 | * handling simple, file-based configuration, 7 | * logging, 8 | * routing, 9 | * deconstructing requests, 10 | * unmarshaling JSON entities to Scala's case classes, 11 | * marshaling Scala's case classes to JSON responses, 12 | * error handling, 13 | * issuing requests to external services, 14 | * testing with mocking of external services. 15 | 16 | It focuses on the HTTP part of the microservices and doesn't talk about database connection handling, etc. 17 | 18 | Check out the code and don't forget to comment or ask questions on [Github](https://github.com/theiterators/pekko-http-microservice) and [Twitter](https://twitter.com/luksow). 19 | 20 | Below you will find a brief tutorial about how the service works. You can: 21 | 22 | * learn what a microservice is, 23 | * check what our microservice does, 24 | * or you can go straight to the code. 25 | 26 | ## What is a microservice? 27 | 28 | Microservice is a tiny standalone program that can be used as a component of a bigger distributed system. Microservices: 29 | 30 | * are short and concise, 31 | * process only one bounded domain. 32 | 33 | In order to be readable and rewritable, code in microservices is usually very short and brief. It's usually responsible for processing only one type of data (in this project it is IP location data). They rarely use the high level of abstraction over databases, networking, and other components. It all makes them easier to understand and easier to reuse in multiple projects. 34 | 35 | Next: What does the example microservice do? 36 | 37 | ## Geolocation of IP addresses 38 | 39 | Our example microservice has two main features. It should: 40 | 41 | * locate an IP address, 42 | * compute distances between locations of two IP addresses. 43 | 44 | It should do all that by exposing two HTTP JSON endpoints: 45 | 46 | * `GET /ip/X.X.X.X` — which returns given IP's geolocation data, 47 | * `POST /ip` — which returns distance between two IPs geolocations given JSON request `{"ip1": "X.X.X.X", "ip2": "Y.Y.Y.Y"}`. 48 | 49 | Next: Let's see how to run it! 50 | 51 | ## Running the template 52 | 53 | Issue `$ sbt "~reStart"` to see the microservice compiling and running. 54 | 55 | You can check out where are Google DNS servers by opening [`http://localhost:9000/ip/8.8.8.8`](http://localhost:9000/ip/8.8.8.8). As you can see in the URL, the browser will send GET request to the first endpoint. 56 | 57 | You can also check our endpoints using `curl` command line tool: 58 | 59 | $ curl http://localhost:9000/ip/8.8.8.8 60 | 61 | and 62 | 63 | $ curl -X POST -H 'Content-Type: application/json' http://localhost:9000/ip -d '{"ip1": "8.8.8.8", "ip2": "8.8.4.4"}' 64 | 65 | for the second endpoint. 66 | 67 | If you don't have curl installed you can install it [from the source](http://curl.haxx.se/docs/install.html), using your OS package manager or you can use Postman REST Client in your browser. 68 | 69 | Next: Let's see how our responses look like 70 | 71 | ## The Geolocation IP responses 72 | 73 | Responses should look like that: 74 | 75 | { 76 | "city": "Mountain View", 77 | "query": "8.8.8.8", 78 | "country": "United States", 79 | "lon": -122.0881, 80 | "lat": 37.3845 81 | } 82 | 83 | for the first endpoint and 84 | 85 | { 86 | "distance": 4347.6243474947, 87 | "ip1Info": { 88 | "city": "Mountain View", 89 | "query": "8.8.8.8", 90 | "country": "United States", 91 | "lon": -122.0881, 92 | "lat": 37.3845 93 | }, 94 | "ip2Info": { 95 | "city": "Norwell", 96 | "query": "93.184.216.34", 97 | "country": "United States", 98 | "lon": -70.8228, 99 | "lat": 42.1508 100 | } 101 | } 102 | 103 | In the sbt output you can see the request/response logs the app generates. 104 | 105 | Next: Now as we know what our microservice does, let's open up the code. 106 | 107 | ## Code overview 108 | 109 | There are four significant parts of the code. These are: 110 | 111 | * [build.sbt](https://github.com/theiterators/pekko-http-microservice/blob/master/build.sbt) and [plugins.sbt](https://github.com/theiterators/pekko-http-microservice/blob/master/project/plugins.sbt) — the build scripts, 112 | * [application.conf](https://github.com/theiterators/pekko-http-microservice/blob/master/src/main/resources/application.conf) — the seed configuration for our microservice, 113 | * [PekkoHttpMicroservice.scala](https://github.com/theiterators/pekko-http-microservice/blob/master/src/main/scala/PekkoHttpMicroservice.scala) — our main Scala file. 114 | * [ServiceSpec.scala](https://github.com/theiterators/pekko-http-microservice/blob/master/src/test/scala/ServiceSpec.scala) — an example `pekko-http` server test. 115 | 116 | The build scripts let SBT download all the dependencies for our project (including `pekko-http`). They are described inside the build scripts part of the tutorial. 117 | 118 | Configuration for our microservice is described in the configuration part of the tutorial. 119 | 120 | The code implementing our microservice's logic is described in the "microservice's code" section. 121 | 122 | ## Build scripts 123 | 124 | [build.sbt](https://github.com/theiterators/pekko-http-microservice/blob/master/build.sbt) and [plugins.sbt](https://github.com/theiterators/pekko-http-microservice/blob/master/project/plugins.sbt) hold the configuration for our build procedure. 125 | 126 | ### build.sbt 127 | 128 | `build.sbt` provides our project with typical meta-data like project names and versions, declares Scala compiler flags and lists the dependencies. 129 | 130 | * `pekko-actor` is the cornerstone of Actor system that `pekko-http` and `pekko-stream` are based on. 131 | * `pekko-stream` is the library implementing Reactive Streams using Pekko actors — a framework for building reactive applications. 132 | * `pekko-http` is core library for creating reactive HTTP streams. 133 | * `circe-core` is a library for handling JSONs. 134 | * `circe-generic` is an extension to `circe-core` that offers auto generation of JSON encoders and decoders for case classes. 135 | * `pekko-http-circe` is a library for marshaling `circe`'s JSONs into requests and responses. 136 | * `pekko-testkit` is a library that helps testing `pekko`. 137 | * `pekko-http-testkit` is a library that helps testing `pekko-http` routing and responses. 138 | * `scalatest` is a standard Scala testing library. 139 | 140 | ### plugins.sbt 141 | 142 | There are four plugins used in our project. These are: 143 | 144 | * `sbt-revolver` which is helpful for development. It recompiles and runs our microservice every time the code in files changes (`~reStart` sbt command). Notice that it is initialized inside `build.sbt`. 145 | * `sbt-assembly` is a great library that lets us deploy our microservice as a single .jar file. 146 | * `sbt-native-packager` is needed by Heroku to stage the app. 147 | * `sbt-updates` provides a handy sbt command `dependencyUpdates` that list dependencies that could be updated. 148 | 149 | Next: As we know what are the dependencies of our project, let's see what is the minimal configuration needed for the project. 150 | 151 | ## Configuration 152 | 153 | The seed configuration for our microservice is available in the [application.conf](https://github.com/theiterators/pekko-http-microservice/blob/master/src/main/resources/application.conf). It consists of three things: 154 | 155 | * `pekko` — Pekko configuration, 156 | * `http` — HTTP server configuration, 157 | * `services` — external endpoints configuration. 158 | 159 | The Pekko part of the configuration will let us see more log messages on the console when developing the microservice. 160 | 161 | HTTP interface needs to be given an interface that it will run on and port that will listen for new HTTP requests. 162 | 163 | Our microservice uses external service `http://ip-api.com/` to find where the IP we're trying to find is. 164 | 165 | When deploying microservice as a `.jar` file, one can overwrite the configuration values when running the jar. 166 | 167 | java -jar microservice.jar -Dservices.ip-api.port=8080 168 | 169 | Using a configuration management system is also recommended as the amount of variables rises quickly. It is hard to maintain configuration files across the more complex microservice architecture. 170 | 171 | Next: Let's see how is our configuration used in the code. 172 | 173 | ## Microservice's code 174 | 175 | All of the code is held in [PekkoHttpMicroservice.scala](https://github.com/theiterators/pekko-http-microservice/blob/master/src/main/scala/PekkoHttpMicroservice.scala). We can distinguish 6 parts of the code. These are: 176 | 177 | * the imports, 178 | * type declarations and business domain, 179 | * protocols, 180 | * networking logic, 181 | * routes, 182 | * main App declaration. 183 | 184 | The names, order, and configuration are not standardized, but the list above will make it easier for us to reason about this code. 185 | 186 | We won't get into many details about imports. The only thing worth remembering is that there are many `implicit values` imported and one should be cautious when removing the imports, as many of them can be marked as unused by one's IDE. 187 | 188 | This section of the tutorial explains: 189 | 190 | * How to use Scala types in HTTP microservice? 191 | * How to do external HTTP requests? 192 | * How to declare HTTP routes? 193 | * What do our tests do? 194 | 195 | ## Scala types and protocols 196 | 197 | To see the usage of Scala types and protocols inside our microservice open up the [PekkoHttpMicroservice.scala](https://github.com/theiterators/pekko-http-microservice/blob/master/src/main/scala/PekkoHttpMicroservice.scala#L22). We have three type of types there: 198 | 199 | * `IpApiResponse` and `IpApiResponseStatus` — a case class (with dedicated enum) that models external API response. 200 | * `IpPairSummaryRequest` — a case class that models our JSON HTTP request's body. 201 | * `IpInfo` and `IpInfoSummary` — case classes are used as an intermediate form of data that can be converted to response JSON. 202 | 203 | ### Modeling requests 204 | 205 | `pekko-http` can unmarshal any JSON request into a type. This way we can validate incoming requests and pass only the ones that are well-formed and complete. The easiest way to model requests is to create algebraic data types and instrument them with validations in a constructor (typical methods include using Scala's Predef library with its `require` method). Example: 206 | 207 | case class IpPairSummaryRequest(...) { 208 | ... 209 | require(ip1..split('.').map(_.toInt).map({s => s >= 0 && s <= 255}).fold(true)(_ && _), "wrong IP address") 210 | ... 211 | } 212 | 213 | ### Forming JSON response 214 | 215 | One of the great features of `pekko-http` is response marshaling. The responses will be implicitly converted into JSON whether they are `Option[T]`, `Future[T]`, etc. Proper errors and response codes will also be generated. 216 | 217 | Using this feature requires: 218 | 219 | * Having contents of `ErrorAccumulatingCirceSupport` in scope, 220 | * declaring implicit JSON converters (here it's done inside `Protocols` trait). 221 | 222 | Next: Making external HTTP requests. 223 | 224 | ## Making an external HTTP request 225 | 226 | Handling communication with external HTTP services is done inside [`Service`](https://github.com/theiterators/pekko-http-microservice/blob/master/src/main/scala/PekkoHttpMicroservice.scala#L64) trait. 227 | 228 | ### Making an HTTP request 229 | 230 | Making a proper HTTP request using `pekko-http` leverages the Reactive Streams approach. It requires: 231 | 232 | * defining an external service HTTP connection flow, 233 | * defining a proper HTTP request, 234 | * defining this request as a source, 235 | * connecting the request source through external service HTTP connection flow with so-called `Sink`. 236 | 237 | In order for the flow to run, we also need `FlowMaterializer` and `ExecutionContext`. After the request is done, we get the standard `HttpResponse` that we need to handle. 238 | 239 | ### Handling the response 240 | 241 | Handling `HttpResponse` consists of: 242 | 243 | * checking if the request was successful, 244 | * unmarshaling HTTP Entity into a case class. 245 | 246 | The unmarshaling uses the protocol implicit values defined earlier. Unmarshaling works using `Future[T]`s so we can always handle any errors and exceptions raised by our validation logic. 247 | 248 | Next: Declaring routes and responding to HTTP requests. 249 | 250 | ## Routing and running server 251 | 252 | Routing directives can be found in the [`Service`](https://github.com/theiterators/pekko-http-microservice/blob/master/src/main/scala/PekkoHttpMicroservice.scala#L85) trait. 253 | 254 | `pekko-http` provides lots of useful routing directives. One can use multiple directives by nesting them inside one another. The request will go deeper down the nested structure if only it complies with each of the directive's requirements. Some directives filter the requests while others help to deconstruct it. If the request passes all directives, the final `complete(...) {...}` block gets evaluated as a `HttpResponse`. 255 | 256 | ### Routing & filtering directives 257 | 258 | Directives responsible for routing are: 259 | 260 | * `pathPrefix("ip")` — filters the request by its relative URI beginning, 261 | * `path("ip"/"my")` — filters the request by its part of the URI relative to the hostname or `pathPrefix` directive in which it is nested, 262 | * `get` — filters GET requests, 263 | * `post` — filters POST requests, 264 | * and [many more.](https://doc.akka.io/docs/akka-http/current/routing-dsl/directives/index.html) 265 | 266 | ### Deconstructing request 267 | 268 | Directives that let us deconstruct the request: 269 | 270 | * `entity(as[IpPairSummaryRequest])` — unmarshals HTTP entity into an object; useful for handling JSON requests, 271 | * `formFields('field1, 'field2)` — extracts form fields form POST request, 272 | * `headerValueByName("X-Auth-Token")` — extracts a header value by its name, 273 | * `path("member" / Segment / "books")` — the `Segment` part of the directive lets us extract a string from the URI, 274 | * and [many more.](https://doc.akka.io/docs/akka-http/current/routing-dsl/directives/index.html) 275 | 276 | Directives can provide us with some values we can use later to prepare a response: 277 | 278 | headerValueByName("X-Requester-Name") { requesterName => 279 | Ok("Hi " + requesterName) 280 | } 281 | 282 | There are other directives like `logRequestResult` that don't change the flow of the request. We can also create our own directives whenever needed. 283 | 284 | ### Building a response 285 | 286 | If we use JSON marshaling, it is very easy to build a JSON response. All we need to do is to return marshalable type in `complete` directive (ex. `String`, `Future[T]`, `Option[T]`, `StatusCode`, etc.). Most of the HTTP status codes are already implemented in `pekko-http`. Some of them are: 287 | 288 | * `Ok` — 200 response 289 | * `NotFound` — 404 response which is automatically generated when `None` is returned. 290 | * `Unauthorized` — 401 response 291 | * `Bad Request` — 400 response 292 | 293 | Next: Testing `pekko-http`. 294 | 295 | ## Tests 296 | 297 | Check out [simple tests that we prepared](https://github.com/theiterators/pekko-http-microservice/blob/master/src/test/scala/ServiceSpec.scala) and don't forget to run them on your computer (`sbt test`)! 298 | 299 | The interesting parts of the tests are: 300 | 301 | * the syntax of route checking, 302 | * [the way external requests are mocked](https://github.com/theiterators/pekko-http-microservice/blob/master/src/test/scala/ServiceSpec.scala#L19). 303 | 304 | Next: Tutorial summary. 305 | 306 | ## Summary 307 | 308 | And that's it! We hope you enjoyed this tutorial and learned how to write a small microservice that uses `pekko-http`, responds to `GET` and `POST` requests with JSON, and connects with external services through HTTP endpoint. 309 | 310 | Be sure to ping us on [Github](https://github.com/theiterators/pekko-http-microservice) or [Twitter](https://twitter.com/luksow) if you liked it or if you have any questions. -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Akka HTTP microservice example", 3 | "description": "Simple (micro)service which demonstrates how to accomplish tasks typical for REST service using Akka HTTP.", 4 | "website": "https://typesafe.com/activator/template/akka-http-microservice", 5 | "success_url": "/ip/8.8.8.8" 6 | } 7 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import sbt.librarymanagement.ConflictWarning 2 | 3 | enablePlugins(JavaAppPackaging) 4 | 5 | name := "pekko-http-microservice" 6 | organization := "com.theiterators" 7 | version := "1.0" 8 | scalaVersion := "3.4.1" 9 | 10 | conflictWarning := ConflictWarning.disable 11 | 12 | scalacOptions := Seq("-unchecked", "-deprecation", "-encoding", "utf8") 13 | 14 | resolvers ++= Resolver.sonatypeOssRepos("snapshots") 15 | 16 | libraryDependencies ++= { 17 | val pekkoHttpV = "1.0.1" 18 | val pekkoV = "1.0.3" 19 | val circeV = "0.14.9" 20 | val scalaTestV = "3.2.19" 21 | val akkaHttpCirceV = "1.39.2" 22 | val pekkoHttpJsonV = "2.6.0" 23 | 24 | Seq( 25 | "org.apache.pekko" %% "pekko-actor" % pekkoV, 26 | "org.apache.pekko" %% "pekko-stream" % pekkoV, 27 | "org.apache.pekko" %% "pekko-http" % pekkoHttpV, 28 | "org.apache.pekko" %% "pekko-testkit" % pekkoV % "test", 29 | "org.apache.pekko" %% "pekko-http-testkit" % pekkoHttpV % "test", 30 | "io.circe" %% "circe-core" % circeV, 31 | "io.circe" %% "circe-parser" % circeV, 32 | "io.circe" %% "circe-generic" % circeV, 33 | "com.github.pjfanning" %% "pekko-http-circe" % pekkoHttpJsonV, 34 | "org.scalatest" %% "scalatest" % scalaTestV % "test" 35 | ) 36 | } 37 | 38 | Revolver.settings 39 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.8.3 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0") 2 | 3 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.5") 4 | 5 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.6.4") 6 | 7 | addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16") 8 | -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | pekko { 2 | loglevel = DEBUG 3 | } 4 | 5 | http { 6 | interface = "0.0.0.0" 7 | port = 9000 8 | } 9 | 10 | services { 11 | ip-api { 12 | host = "ip-api.com" 13 | port = 80 14 | } 15 | } -------------------------------------------------------------------------------- /src/main/scala/PekkoHttpMicroservice.scala: -------------------------------------------------------------------------------- 1 | import org.apache.pekko.actor.ActorSystem 2 | import org.apache.pekko.event.{Logging, LoggingAdapter} 3 | import org.apache.pekko.http.scaladsl.Http 4 | import org.apache.pekko.http.scaladsl.client.RequestBuilding 5 | import org.apache.pekko.http.scaladsl.marshalling.ToResponseMarshallable 6 | import org.apache.pekko.http.scaladsl.model.{HttpRequest, HttpResponse} 7 | import org.apache.pekko.http.scaladsl.model.StatusCodes._ 8 | import org.apache.pekko.http.scaladsl.server.Directives._ 9 | import org.apache.pekko.http.scaladsl.server.Route 10 | import org.apache.pekko.http.scaladsl.unmarshalling.Unmarshal 11 | import org.apache.pekko.stream.scaladsl.{Flow, Sink, Source} 12 | import com.typesafe.config.Config 13 | import com.typesafe.config.ConfigFactory 14 | import com.github.pjfanning.pekkohttpcirce._ 15 | import io.circe.Decoder.Result 16 | import io.circe.{Decoder, Encoder, HCursor, Json} 17 | 18 | import java.io.IOException 19 | import scala.concurrent.{ExecutionContext, Future} 20 | import scala.math._ 21 | 22 | enum IpApiResponseStatus { 23 | case Success, Fail 24 | } 25 | case class IpApiResponse(status: IpApiResponseStatus, message: Option[String], query: String, country: Option[String], city: Option[String], lat: Option[Double], lon: Option[Double]) 26 | 27 | case class IpInfo(query: String, country: Option[String], city: Option[String], lat: Option[Double], lon: Option[Double]) 28 | 29 | case class IpPairSummaryRequest(ip1: String, ip2: String) 30 | 31 | case class IpPairSummary(distance: Option[Double], ip1Info: IpInfo, ip2Info: IpInfo) 32 | 33 | object IpPairSummary { 34 | def apply(ip1Info: IpInfo, ip2Info: IpInfo): IpPairSummary = IpPairSummary(calculateDistance(ip1Info, ip2Info), ip1Info, ip2Info) 35 | 36 | private def calculateDistance(ip1Info: IpInfo, ip2Info: IpInfo): Option[Double] = { 37 | (ip1Info.lat, ip1Info.lon, ip2Info.lat, ip2Info.lon) match { 38 | case (Some(lat1), Some(lon1), Some(lat2), Some(lon2)) => 39 | // see http://www.movable-type.co.uk/scripts/latlong.html 40 | val φ1 = toRadians(lat1) 41 | val φ2 = toRadians(lat2) 42 | val Δφ = toRadians(lat2 - lat1) 43 | val Δλ = toRadians(lon2 - lon1) 44 | val a = pow(sin(Δφ / 2), 2) + cos(φ1) * cos(φ2) * pow(sin(Δλ / 2), 2) 45 | val c = 2 * atan2(sqrt(a), sqrt(1 - a)) 46 | Option(EarthRadius * c) 47 | case _ => None 48 | } 49 | } 50 | 51 | private val EarthRadius = 6371.0 52 | } 53 | 54 | trait Protocols extends ErrorAccumulatingCirceSupport { 55 | import io.circe.generic.semiauto._ 56 | implicit val ipApiResponseStatusDecoder: Decoder[IpApiResponseStatus] = Decoder.decodeString.map(s => IpApiResponseStatus.valueOf(s.capitalize)) 57 | implicit val ipApiResponseDecoder: Decoder[IpApiResponse] = deriveDecoder 58 | implicit val ipInfoDecoder: Decoder[IpInfo] = deriveDecoder 59 | implicit val ipInfoEncoder: Encoder[IpInfo] = deriveEncoder 60 | implicit val ipPairSummaryRequestDecoder: Decoder[IpPairSummaryRequest] = deriveDecoder 61 | implicit val ipPairSummaryRequestEncoder: Encoder[IpPairSummaryRequest] = deriveEncoder 62 | implicit val ipPairSummaryEncoder: Encoder[IpPairSummary] = deriveEncoder 63 | implicit val ipPairSummaryDecoder: Decoder[IpPairSummary] = deriveDecoder 64 | } 65 | 66 | trait Service extends Protocols { 67 | implicit val system: ActorSystem 68 | implicit def executor: ExecutionContext 69 | 70 | def config: Config 71 | val logger: LoggingAdapter 72 | 73 | lazy val ipApiConnectionFlow: Flow[HttpRequest, HttpResponse, Any] = 74 | Http().outgoingConnection(config.getString("services.ip-api.host"), config.getInt("services.ip-api.port")) 75 | 76 | // Please note that using `Source.single(request).via(pool).runWith(Sink.head)` is considered anti-pattern. It's here only for the simplicity. 77 | // See why and how to improve it here: https://github.com/theiterators/akka-http-microservice/issues/32 78 | def ipApiRequest(request: HttpRequest): Future[HttpResponse] = Source.single(request).via(ipApiConnectionFlow).runWith(Sink.head) 79 | 80 | def fetchIpInfo(ip: String): Future[String | IpInfo] = { 81 | ipApiRequest(RequestBuilding.Get(s"/json/$ip")).flatMap { response => 82 | response.status match { 83 | case OK => 84 | Unmarshal(response.entity).to[IpApiResponse].map { ipApiResponse => 85 | ipApiResponse.status match { 86 | case IpApiResponseStatus.Success => IpInfo(ipApiResponse.query,ipApiResponse.country, ipApiResponse.city, ipApiResponse.lat, ipApiResponse.lon) 87 | case IpApiResponseStatus.Fail => s"""ip-api request failed with message: ${ipApiResponse.message.getOrElse("")}""" 88 | } 89 | } 90 | case _ => Unmarshal(response.entity).to[String].flatMap { entity => 91 | val error = s"ip-api request failed with status code ${response.status} and entity $entity" 92 | logger.error(error) 93 | Future.failed(new IOException(error)) 94 | } 95 | } 96 | } 97 | } 98 | 99 | val routes: Route = { 100 | logRequestResult("pekko-http-microservice") { 101 | pathPrefix("ip") { 102 | (get & path(Segment)) { ip => 103 | complete { 104 | fetchIpInfo(ip).map[ToResponseMarshallable] { 105 | case ipInfo: IpInfo => ipInfo 106 | case errorMessage: String => BadRequest -> errorMessage 107 | } 108 | } 109 | } ~ 110 | (post & entity(as[IpPairSummaryRequest])) { ipPairSummaryRequest => 111 | complete { 112 | val ip1InfoFuture = fetchIpInfo(ipPairSummaryRequest.ip1) 113 | val ip2InfoFuture = fetchIpInfo(ipPairSummaryRequest.ip2) 114 | ip1InfoFuture.zip(ip2InfoFuture).map[ToResponseMarshallable] { 115 | case (info1: IpInfo, info2: IpInfo) => IpPairSummary(info1, info2) 116 | case (errorMessage: String, _) => BadRequest -> errorMessage 117 | case (_, errorMessage: String) => BadRequest -> errorMessage 118 | } 119 | } 120 | } 121 | } 122 | } 123 | } 124 | } 125 | 126 | object PekkoHttpMicroservice extends App with Service { 127 | override implicit val system: ActorSystem = ActorSystem() 128 | override implicit val executor: ExecutionContext = system.dispatcher 129 | 130 | override val config = ConfigFactory.load() 131 | override val logger = Logging(system, "pekkoHttpMicroservice") 132 | 133 | Http().newServerAt(config.getString("http.interface"), config.getInt("http.port")).bindFlow(routes) 134 | } 135 | -------------------------------------------------------------------------------- /src/test/scala/ServiceSpec.scala: -------------------------------------------------------------------------------- 1 | import org.apache.pekko.event.NoLogging 2 | import org.apache.pekko.http.scaladsl.model.ContentTypes._ 3 | import org.apache.pekko.http.scaladsl.model.{HttpRequest, HttpResponse} 4 | import org.apache.pekko.http.scaladsl.model.StatusCodes._ 5 | import org.apache.pekko.http.scaladsl.testkit.ScalatestRouteTest 6 | import org.apache.pekko.stream.scaladsl.Flow 7 | import org.scalatest.flatspec.AsyncFlatSpec 8 | import org.scalatest.matchers.should.Matchers 9 | import io.circe.parser.parse 10 | 11 | class ServiceSpec extends AsyncFlatSpec with Matchers with ScalatestRouteTest with Service { 12 | override def testConfigSource = "pekko.loglevel = WARNING" 13 | override def config = testConfig 14 | override val logger = NoLogging 15 | 16 | val ip1Info = IpInfo("8.8.8.8", Option("United States"), Option("Ashburn"), Option(39.03), Option(-77.5)) 17 | val ipApiResponse1 = parse("""{"query":"8.8.8.8","status":"success","continent":"North America","continentCode":"NA","country":"United States","countryCode":"US","region":"VA","regionName":"Virginia","city":"Ashburn","district":"","zip":"20149","lat":39.03,"lon":-77.5,"timezone":"America/New_York","offset":-14400,"currency":"USD","isp":"Google LLC","org":"Google Public DNS","as":"AS15169 Google LLC","asname":"GOOGLE","mobile":false,"proxy":false,"hosting":true}""") 18 | val ip2Info = IpInfo("1.1.1.1", Option("Australia"), Option("South Brisbane"), Option(-27.4766), Option(153.0166)) 19 | val ipApiResponse2 = io.circe.parser.parse("""{"status":"success","country":"Australia","countryCode":"AU","region":"QLD","regionName":"Queensland","city":"South Brisbane","zip":"4101","lat":-27.4766,"lon":153.0166,"timezone":"Australia/Brisbane","isp":"Cloudflare, Inc","org":"APNIC and Cloudflare DNS Resolver project","as":"AS13335 Cloudflare, Inc.","query":"1.1.1.1"}""") 20 | val ipPairSummary = IpPairSummary(ip1Info, ip2Info) 21 | val ipApiErrorResponse = parse("""{"status":"fail","message":"invalid query","query":"asdfg"}""") 22 | 23 | override lazy val ipApiConnectionFlow = Flow[HttpRequest].map { request => 24 | if (request.uri.toString().endsWith(ip1Info.query)) 25 | HttpResponse(status = OK, entity = marshal(ipApiResponse1)) 26 | else if(request.uri.toString().endsWith(ip2Info.query)) 27 | HttpResponse(status = OK, entity = marshal(ipApiResponse2)) 28 | else 29 | HttpResponse(status = OK, entity = marshal(ipApiErrorResponse)) 30 | } 31 | 32 | "Service" should "respond to single IP query" in { 33 | Get(s"/ip/${ip1Info.query}") ~> routes ~> check { 34 | status shouldBe OK 35 | contentType shouldBe `application/json` 36 | responseAs[IpInfo] shouldBe ip1Info 37 | } 38 | 39 | Get(s"/ip/${ip2Info.query}") ~> routes ~> check { 40 | status shouldBe OK 41 | contentType shouldBe `application/json` 42 | responseAs[IpInfo] shouldBe ip2Info 43 | } 44 | } 45 | 46 | it should "respond to IP pair query" in { 47 | Post(s"/ip", IpPairSummaryRequest(ip1Info.query, ip2Info.query)) ~> routes ~> check { 48 | status shouldBe OK 49 | contentType shouldBe `application/json` 50 | responseAs[IpPairSummary] shouldBe ipPairSummary 51 | } 52 | } 53 | 54 | it should "respond with bad request on incorrect IP format" in { 55 | Get("/ip/asdfg") ~> routes ~> check { 56 | status shouldBe BadRequest 57 | responseAs[String].length should be > 0 58 | } 59 | 60 | Post(s"/ip", IpPairSummaryRequest(ip1Info.query, "asdfg")) ~> routes ~> check { 61 | status shouldBe BadRequest 62 | responseAs[String].length should be > 0 63 | } 64 | 65 | Post(s"/ip", IpPairSummaryRequest("asdfg", ip1Info.query)) ~> routes ~> check { 66 | status shouldBe BadRequest 67 | responseAs[String].length should be > 0 68 | } 69 | } 70 | } 71 | --------------------------------------------------------------------------------