├── .clj-condo └── config.edn ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── cljfmt.edn ├── env ├── demo1 │ └── src │ │ └── demo1 │ │ └── main.clj ├── demo2 │ └── src │ │ └── demo2 │ │ └── main.clj ├── demo3 │ └── src │ │ └── demo3 │ │ └── main.clj └── dev │ └── resources │ └── event.json ├── links.md ├── project.clj ├── src └── lambda │ ├── api.clj │ ├── codec.clj │ ├── component.clj │ ├── config.clj │ ├── error.clj │ ├── log.clj │ ├── main.clj │ └── ring.clj ├── test └── lambda │ ├── component_test.clj │ ├── core_test.clj │ ├── gzip_test.clj │ └── json_test.clj └── todo.txt /.clj-condo/config.edn: -------------------------------------------------------------------------------- 1 | {:linters 2 | {:unresolved-symbol 3 | {:exclude 4 | []}}} 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | profiles.clj 5 | pom.xml 6 | pom.xml.asc 7 | *.jar 8 | *.class 9 | /.lein-* 10 | /.nrepl-port 11 | /.prepl-port 12 | .hgignore 13 | .hg/ 14 | 15 | PLATFORM 16 | reports 17 | *.zip 18 | *.build_artifacts.txt 19 | lambda-demo 20 | bootstrap 21 | 22 | node_modules 23 | package-lock.json 24 | package.json 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.9-SNAPSHOT 2 | 3 | - ? 4 | - ? 5 | - ? 6 | 7 | ## 0.1.8-SNAPSHOT 8 | 9 | - ? 10 | - ? 11 | - ? 12 | 13 | ## 0.1.7-SNAPSHOT 14 | 15 | - ? 16 | - ? 17 | - ? 18 | 19 | ## 0.1.6 20 | 21 | - switch from http-kit to babashka-http 22 | - update demo3 23 | - public test lambda 24 | 25 | ## 0.1.5 26 | 27 | - component support 28 | - demo3 added with component 29 | 30 | ## 0.1.4 31 | 32 | - gzip support (tests & readme) 33 | 34 | ## 0.1.3 35 | 36 | - replace cheshire with jsam 37 | - remove ring-core 38 | - api: check for the error field, rethrow 39 | - api: set error type header 40 | - error ns: throw and rethrow 41 | - log ns: exception 42 | - ring wrap exception mw 43 | - update readme 44 | 45 | ## 0.1.2 46 | 47 | - add config namespace 48 | - set http timeout 49 | - change license 50 | - add changelog file 51 | - fix linting 52 | - add managed deps 53 | - pass x-request-id header 54 | 55 | ## 0.1.1 56 | 57 | - add docs in readme 58 | - add demo project 59 | 60 | ## 0.1.0 61 | 62 | - initial release 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | all: lint test 3 | 4 | NI_TAG = ghcr.io/graalvm/native-image:22.2.0 5 | 6 | JAR = target/uberjar/bootstrap.jar 7 | 8 | PWD = $(shell pwd) 9 | 10 | NI_ARGS = \ 11 | --initialize-at-build-time \ 12 | --report-unsupported-elements-at-runtime \ 13 | --no-fallback \ 14 | -jar ${JAR} \ 15 | -J-Dfile.encoding=UTF-8 \ 16 | --enable-http \ 17 | --enable-https \ 18 | -H:+PrintClassInitialization \ 19 | -H:+ReportExceptionStackTraces \ 20 | -H:Log=registerResource \ 21 | -H:Name=bootstrap 22 | 23 | .PHONY: clear 24 | clear: 25 | rm -rf target 26 | rm -rf reports 27 | 28 | repl: 29 | lein repl 30 | 31 | graal-build: 32 | native-image ${NI_ARGS} 33 | 34 | build-binary-docker: ${JAR} 35 | docker run -it --rm -v ${PWD}:/build -w /build ${NI_TAG} ${NI_ARGS} 36 | 37 | build-binary-local: ${JAR} graal-build 38 | 39 | uberjar: 40 | lein with-profile +demo3 uberjar 41 | 42 | bootstrap-zip: 43 | zip -j bootstrap.zip bootstrap 44 | 45 | bootstrap-docker: clear uberjar build-binary-docker bootstrap-zip 46 | 47 | bootstrap-local: uberjar build-binary-local bootstrap-zip 48 | 49 | lint: 50 | clj-kondo --lint src test 51 | cljfmt check 52 | 53 | toc-install: 54 | npm install --save markdown-toc 55 | 56 | toc-build: 57 | node_modules/.bin/markdown-toc -i README.md 58 | 59 | .PHONY: test 60 | test: 61 | lein test 62 | 63 | release: lint test 64 | lein release 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lambda 2 | 3 | A small framework to run AWS Lambdas compiled with Native Image. 4 | 5 | ## Table of Contents 6 | 7 | 8 | 9 | - [Motivation & Benefits](#motivation--benefits) 10 | - [Installation](#installation) 11 | - [Writing Your Lambda](#writing-your-lambda) 12 | * [Prepare The Code](#prepare-the-code) 13 | * [Compile It](#compile-it) 14 | + [Linux (Local Build)](#linux-local-build) 15 | + [On MacOS (Docker)](#on-macos-docker) 16 | * [Create a Lambda in AWS](#create-a-lambda-in-aws) 17 | * [Configuration](#configuration) 18 | * [Deploy and Test It](#deploy-and-test-it) 19 | - [Ring Support (Serving HTTP events)](#ring-support-serving-http-events) 20 | - [Gzip Support for Ring](#gzip-support-for-ring) 21 | - [Sharing the State Between Events](#sharing-the-state-between-events) 22 | - [Component Support](#component-support) 23 | - [Demo](#demo) 24 | - [Misc](#misc) 25 | 26 | 27 | 28 | ## Motivation & Benefits 29 | 30 | [search]: https://clojars.org/search?q=lambda 31 | 32 | There are a lot of Lambda Clojure libraries so far: a [quick search][search] on 33 | Clojars gives several screens of them. What is the point of making a new one? 34 | Well, because none of the existing libraries covers my requirements, namely: 35 | 36 | - I want a framework free from any Java SDK, but pure Clojure only. 37 | - I want it to compile into a single binary file so no environment is needed. 38 | - The deployment process must be extremely simple. 39 | 40 | As the result, *this* framework: 41 | 42 | - Narrow dependencies to keep the output file as thin as possible; 43 | - Provides an endless loop that consumes events from AWS and handles them. You 44 | only submit a function that processes an event. 45 | - Provides a Ring middleware that turns HTTP events into a Ring handler. Thus, 46 | you can easily serve HTTP requests with Ring stack. 47 | - Has a built-in logging facility. 48 | - Stuart Sierra's Component library support. 49 | - Provides a bunch of Make commands to build a zipped bootstrap file. 50 | 51 | ## Installation 52 | 53 | Leiningen/Boot 54 | 55 | ``` 56 | [com.github.igrishaev/lambda "0.1.6"] 57 | ``` 58 | 59 | Clojure CLI/deps.edn 60 | 61 | ``` 62 | com.github.igrishaev/lambda {:mvn/version "0.1.6"} 63 | ``` 64 | 65 | ## Writing Your Lambda 66 | 67 | ### Prepare The Code 68 | 69 | Create a core module with the following code: 70 | 71 | ```clojure 72 | (ns demo.core 73 | (:require 74 | [lambda.log :as log] 75 | [lambda.main :as main]) 76 | (:gen-class)) 77 | 78 | (defn handler [event] 79 | (log/infof "Event is: %s" event) 80 | (process-event ...) 81 | {:result [42]}) 82 | 83 | (defn -main [& _] 84 | (main/run handler)) 85 | ``` 86 | 87 | The `handler` function takes a single argument which is a parsed Lambda 88 | payload. The `lambda.log` namespace provides `debugf`, `infof`, and `errorf` 89 | macros for logging. In the `-main` function you start an endless cycle by 90 | calling the `run` function. 91 | 92 | On each step of this cycle, the framework fetches a new event, processes it with 93 | the passed handler and submits the result to AWS. Should the handler fail, it 94 | catches an exception and reports it as well without interrupt the cycle. Thus, 95 | you don't need to `try/catch` in your handler. 96 | 97 | ### Compile It 98 | 99 | Once you have the code, compile it with GraalVM and Native image. The `Makefile` 100 | of this repository has all the targets you need. You can borrow them with slight 101 | changes. Here are the basic definitions: 102 | 103 | ```make 104 | NI_TAG = ghcr.io/graalvm/native-image:22.2.0 105 | 106 | JAR = target/uberjar/bootstrap.jar 107 | 108 | PWD = $(shell pwd) 109 | 110 | NI_ARGS = \ 111 | --initialize-at-build-time \ 112 | --report-unsupported-elements-at-runtime \ 113 | --no-fallback \ 114 | -jar ${JAR} \ 115 | -J-Dfile.encoding=UTF-8 \ 116 | --enable-http \ 117 | --enable-https \ 118 | -H:+PrintClassInitialization \ 119 | -H:+ReportExceptionStackTraces \ 120 | -H:Log=registerResource \ 121 | -H:Name=bootstrap 122 | 123 | uberjar: 124 | lein <...> uberjar 125 | 126 | bootstrap-zip: 127 | zip -j bootstrap.zip bootstrap 128 | ``` 129 | 130 | Pay attention to the following: 131 | 132 | - Ensure the jar name is set to `bootstrap.jar` in your project. This might be 133 | done by setting these in your `project.clj`: 134 | 135 | ```clojure 136 | {:target-path "target/uberjar" 137 | :uberjar-name "bootstrap.jar"} 138 | ``` 139 | 140 | - The `NI_ARGS` might be extended with resources, e.g. if you want an EDN config 141 | file baked into the binary file. 142 | 143 | Then compile the project either on Linux natively or with Docker. 144 | 145 | #### Linux (Local Build) 146 | 147 | On Linux, add the following Make targets: 148 | 149 | ```make 150 | graal-build: 151 | native-image ${NI_ARGS} 152 | 153 | build-binary-local: ${JAR} graal-build 154 | 155 | bootstrap-local: uberjar build-binary-local bootstrap-zip 156 | ``` 157 | 158 | Then run `make bootstrap-local`. You'll get a file called `bootstrap.zip` with a single binary file `bootstrap` inside. 159 | 160 | #### On MacOS (Docker) 161 | 162 | On MacOS, add these targets: 163 | 164 | ```make 165 | build-binary-docker: ${JAR} 166 | docker run -it --rm -v ${PWD}:/build -w /build ${NI_TAG} ${NI_ARGS} 167 | 168 | bootstrap-docker: uberjar build-binary-docker bootstrap-zip 169 | ``` 170 | 171 | Run `make bootstrap-docker` to get the same file but compiled in a Docker 172 | image. 173 | 174 | ### Create a Lambda in AWS 175 | 176 | Create a Lambda function in AWS. For the runtime, choose a custom one called 177 | `provided.al2` which is based on Amazon Linux 2. The architecture (x86_64/arm64) 178 | should match the architecture of your machine. For example, as I build the 179 | project on Mac M1, I choose arm64. 180 | 181 | ### Configuration 182 | 183 | There are some options you can override with environment variables, namely: 184 | 185 | | Var | Default | Comment | 186 | |--------------------------|------------------|-------------------------------------------------| 187 | | `LAMBDA_RUNTIME_TIMEOUT` | 900000 (15 mins) | How long to wait when polling for a new event | 188 | | `LAMBDA_RUNTIME_VERSION` | 2018-06-01 | Which Runtime API version to use | 189 | | `AWS_LAMBDA_USE_GZIP` | nil | Forcibly gzip-encode Ring responses (see below) | 190 | 191 | ### Deploy and Test It 192 | 193 | Upload the `bootstrap.zip` file from your machine to the lambda. With no 194 | compression, the `bootstrap` file takes 25 megabytes. In zip, it's about 9 195 | megabytes so you can skip uploading it to S3 first. 196 | 197 | Test you Lambda in the console to ensure it works. 198 | 199 | ## Ring Support (Serving HTTP events) 200 | 201 | AWS Lambda can serve HTTP requests as events. Each HTTP request gets transformed 202 | into a special message which your lambda processes. It must return another 203 | message that forms an HTTP response. 204 | 205 | [ring]: https://github.com/ring-clojure/ring 206 | 207 | This library brings a number of middleware that turn a lambda into 208 | [Ring-compatible][ring] HTTP server. 209 | 210 | There are the following middleware wrappers in the `lambda.ring` namespace: 211 | 212 | - `wrap-ring-event`: turns an incoming HTTP event into a Ring request map, 213 | processes it and turns a Ring response map into an Lambda-compatible HTTP 214 | message. 215 | 216 | - `wrap-ring-exception`: captures any uncaught exception happened while handling 217 | an HTTP request. Log it and return an error response (500 Internal server 218 | error). 219 | 220 | [ring-json]: https://github.com/ring-clojure/ring-json 221 | 222 | To not depend on [ring-json][ring-json] (which in turn depends on Cheshire), we 223 | provide our own tree middlware for incoming and outcoming JSON: 224 | 225 | - `wrap-json-body`: if the request was JSON, replace the `:body` field with 226 | a parsed payload. 227 | 228 | - `wrap-json-params`: the same but puts the data into the `:json-params` 229 | field. In addition, if the data was a map, merge it into the `:params` map. 230 | 231 | - `wrap-json-response`: if the body of the response was a collection, encode it 232 | into a JSON string and add the Content-Type: application/json header. 233 | 234 | [jsam]: https://github.com/igrishaev/jsam 235 | 236 | These three middleware mimic their counterparts from Ring-json but rely on the 237 | JSam library to keep dependencies as narrow as possible. Each middleware, in 238 | addition to a ring handler, accepts an optional map of JSON settings. 239 | 240 | The following example shows how to build a stack of middleware properly: 241 | 242 | ~~~clojure 243 | (ns some.demo 244 | (:gen-class) 245 | (:require 246 | [lambda.main :as main] 247 | [lambda.ring :as ring])) 248 | 249 | (defn handler [request] 250 | (let [{:keys [request-method 251 | uri 252 | headers 253 | body]} 254 | request] 255 | ;; you can branch depending on method and uri, 256 | ;; or use compojure/reitit 257 | {:status 200 258 | :headers {"foo" "bar"} 259 | :body {:some "JSON date"}})) 260 | 261 | (def fn-event 262 | (-> handler 263 | (ring/wrap-json-body) 264 | (ring/wrap-json-response) 265 | (ring/wrap-ring-exception) 266 | (ring/wrap-ring-event))) 267 | 268 | (defn -main [& _] 269 | (main/run fn-event)) 270 | ~~~ 271 | 272 | For query- or form parameters, you can use classic `wrap-params`, 273 | `wrap-keyword-params`, and similar utilities from `ring.middleware.*` 274 | namespaces. For this, introduce the `ring-core` library into your project. 275 | 276 | ## Gzip Support for Ring 277 | 278 | The library provides a special Ring middlware to handle gzip logic. Apply it as 279 | follows: 280 | 281 | ~~~clojure 282 | (def fn-event 283 | (-> handler 284 | (ring/wrap-json-body) 285 | (ring/wrap-json-response) 286 | (ring/wrap-gzip) ;; -- this 287 | (ring/wrap-ring-exception) 288 | (ring/wrap-ring-event))) 289 | ~~~ 290 | 291 | This is what the middleware does under the hood: 292 | 293 | - if a client sends a gzipped payload and the `Content-Encoding` header is 294 | `gzip`, the incoming `:body` field gets wrapped with the `GzipInputStream` 295 | class. By reading from it, you'll get the origin payload. Useful when sending 296 | vast JSON objects to Lambda via HTTP. 297 | 298 | - If a client sends a header `Accept-Encoding` with `gzip` inside, the body of a 299 | response gets gzipped, and the `Content-Encoding: gzip` header is set. It 300 | greatly saves traffic. In addition, remember about a limitation in AWS: a 301 | response cannot exceed 6Mbs. Gzipping helps bypass this limit. 302 | 303 | - If there is a non-empty env var `AWS_LAMBDA_USE_GZIP` set for this Lambda, the 304 | response is always gzipped no matter what client specifies in the 305 | `Accept-Encoding` header. 306 | 307 | Although enabling gzip looks trivial, missing it might lead to very strange 308 | things. Personally I spent several a couple of days investigating an issue when 309 | AWS says "the content was too large". Turned out, the culprit was **double JSON 310 | encoding**. When you return JSON from Ring, you encode it once. But when Lambda 311 | runtime sends this message to AWS, it gets JSON-encoded again. This adds extra 312 | slashes and blows up payload by 15-20%. For details, see these pages: 313 | 314 | - [A StackOverflow question with my answer](https://stackoverflow.com/questions/66971400/aws-lambda-body-size-is-too-large-error-but-body-size-is-under-limit) 315 | - [A question on AWS:repost with no answer](https://repost.aws/questions/QU57r4NMQIQROXqW4Vl6YDBQ) 316 | - [My blog post (in Russian, use Google Translate)](https://grishaev.me/aws-1/) 317 | 318 | ## Sharing the State Between Events 319 | 320 | In AWS, a Lambda can process several events if they happen in series. Thus, it's 321 | useful to preserve the state between the handler calls. A state can be a config 322 | map read from a resource or an open TCP connection. 323 | 324 | An easy way to share the state is to close your handler function over some 325 | variables. In this case, the handler is not a plain function but a function that 326 | returns a function: 327 | 328 | ~~~clojure 329 | (defn process-event [db event] 330 | (jdbc/with-transaction [tx db] 331 | (jdbc/insert! tx ...) 332 | (jdbc/delete! tx ...))) 333 | 334 | 335 | (defn make-handler [] 336 | 337 | (let [config 338 | (-> "config.edn" 339 | io/resource 340 | aero/read-config) 341 | 342 | db 343 | (jdbc/get-connection (:db config))] 344 | 345 | (fn [event] 346 | (process-event db event)))) 347 | 348 | 349 | (defn -main [& _] 350 | (let [handler (make-handler)] 351 | (main/run handler))) 352 | ~~~ 353 | 354 | The `make-handler` call builds a function closed over the `db` variable which 355 | holds a persistent connection to a database. Under the hood, it calls the 356 | `process-event` function which accepts the `db` as an argument. The connection 357 | stays persistent and won't be created from scratch every time you process an 358 | event. This, of course, applies only to a case when you have multiple events 359 | served in series. 360 | 361 | Another way to preserve state across multiple Lambda invocations is to use 362 | frameworks like Component, Integrant, or Mount. These libraries bootstrap global 363 | entities once at the beginning. For example, a database connection pool is 364 | created once and then shared with a message handler. 365 | 366 | The section below describes how to use the Component framework with the Lambda 367 | library. 368 | 369 | ## Component Support 370 | 371 | The `lambda.component` namespace ships a function called `lambda` to spawn a 372 | component (in terms of Stuart Sierra's Component library). When started, it runs 373 | a separate thread that consumes messages from Lambda runtime, processes them and 374 | submits positive or negative acknowledge. On every iteration, the logic checks 375 | if a thread was interrupted. When it was, the endless cycle exits. Stopping a 376 | component means interrupting the thread and joining it (will be blocked until 377 | the current message gets processed). 378 | 379 | The component depends on a `:handler` slot which should be a function (or an 380 | object that implements 1-arity `invoke` method from `clojure.lang.IFn`). You can 381 | pass this handler using constructor as well: 382 | 383 | ~~~clojure 384 | (ns some.namespace 385 | (:require 386 | [com.stuartsierra.component :as component] 387 | [lambda.component :as lc])) 388 | 389 | (defn event-handler [message] 390 | ...) 391 | 392 | (def c (lc/lambda event-handler)) 393 | 394 | 395 | (def c-started 396 | (component/start c)) 397 | 398 | ;; the endless message processing loop starts in the background 399 | 400 | (component/stop c-started) 401 | 402 | ;; the loop stops. Might take a while to join the thread. 403 | ~~~ 404 | 405 | This was a toy example: in production, you never run/stop components 406 | manually. There is a demo project located in `env/demo3` with a system of 407 | components which is somewhat close to reality. Here is a fragment from it: 408 | 409 | ~~~clojure 410 | (ns demo3.main 411 | (:gen-class) 412 | (:require 413 | [com.stuartsierra.component :as component] 414 | [lambda.component :as lc] 415 | [lambda.main :as main] 416 | [lambda.ring :as ring])) 417 | 418 | ... 419 | 420 | (defn make-system [] 421 | (component/system-map 422 | 423 | :counter 424 | (new-counter) 425 | 426 | :handler 427 | (-> {} 428 | (map->RingHandler) 429 | (component/using [:counter])) 430 | 431 | :lambda 432 | (-> (lc/lambda) 433 | (component/using [:handler])))) 434 | 435 | 436 | (defn -main [& _] 437 | (-> (make-system) 438 | (component/start))) 439 | ~~~ 440 | 441 | The namespace produces a dedicated class (see the `(:gen-class)` form). The 442 | `make-system` builds a system of components on demand. It must be built in 443 | runtime rather than be a top-level `def` definition because `native-image` 444 | freezes the world, and you'll get weird behavior. 445 | 446 | The `:lambda` component depends on a `:handler` component. Here is a definition: 447 | 448 | ~~~clojure 449 | (defrecord RingHandler [counter] 450 | component/Lifecycle 451 | (start [this] 452 | (-> (make-handler counter) 453 | (ring/wrap-json-body) 454 | (ring/wrap-json-response) 455 | (ring/wrap-gzip) 456 | (ring/wrap-ring-exception) 457 | (ring/wrap-ring-event)))) 458 | ~~~ 459 | 460 | When started, it creates a Ring handler and wraps it with a series of 461 | middleware. It's important that we create handler in runtime because it depends 462 | on the `counter` component, which has not been initialized yet. The 463 | `make-handler` function produces a Ring handler with some simple branching: 464 | 465 | ~~~clojure 466 | (defn make-handler [counter] 467 | (fn [request] 468 | (let [{:keys [uri request-method]} 469 | request] 470 | (case [request-method uri] 471 | 472 | [:get "/"] 473 | (handler-index request counter) 474 | 475 | [:get "/hello"] 476 | (handler-hello request) 477 | 478 | (response-default request counter))))) 479 | ~~~ 480 | 481 | The `counter` component is simple: it's an atom closed over a bunch of methods 482 | to count how many times a certain page was seen: 483 | 484 | ~~~clojure 485 | (defprotocol ICounter 486 | (-inc-page [this uri]) 487 | (-get-page [this uri]) 488 | (-stats [this])) 489 | 490 | (defn new-counter [] 491 | (let [-state (atom {})] 492 | (reify ICounter 493 | (-inc-page [this uri] 494 | (swap! -state update uri (fnil inc 0))) 495 | (-get-page [this uri] 496 | (get @-state uri 0)) 497 | (-stats [this] 498 | @-state)))) 499 | ~~~ 500 | 501 | Once started, the system bootstraps all the components. The `lambda` component 502 | processes messages in the background like an ordinary HTTP Ring server does. 503 | 504 | See the `env/demo3/src/demo3/main.clj` file for full example. 505 | 506 | It's important that the `Lambda` library doesn't depend on Component. It extends 507 | the `LambdaHandler` object with metadata. 508 | 509 | You can easily extend it with Integrant: 510 | 511 | ~~~clojure 512 | (def config 513 | {:lambda/loop {:handler #ig/ref :ring/handler} 514 | :ring/handler {}}) 515 | 516 | (defmethod ig/init-key :ring/handler [_ _] 517 | (-> (make-handler ...) 518 | (ring/wrap-json-body) 519 | (ring/wrap-json-response) 520 | (ring/wrap-gzip) 521 | (ring/wrap-ring-exception) 522 | (ring/wrap-ring-event))) 523 | 524 | (defmethod ig/init-key :lambda/loop [_ {:keys [handler]}] 525 | (lc/start (lc/lambda handler))) 526 | 527 | (defmethod ig/halt-key! :lambda/loop [_ handler] 528 | (lc/stop handler)) 529 | ~~~ 530 | 531 | Mount is even easier: 532 | 533 | ~~~clojure 534 | (require '[mount.core :refer [defstate]]) 535 | 536 | (defstate lambda 537 | :start (lc/start (lc/lambda handler)) 538 | :stop (lc/stop lambda)) 539 | ~~~ 540 | 541 | ## Demo 542 | 543 | [test-lambda]: https://kpryignyuxqx3wwuss7oqvox7q0yhili.lambda-url.us-east-1.on.aws/ 544 | 545 | There is a [public Lambda function][test-lambda] available for tests and 546 | benchmarks. The index page (`GET /`) holds instructions about what you can do 547 | with it. 548 | 549 | ## Misc 550 | 551 | ~~~ 552 | ©©©©©©©©©©©©©©©©©©©©©©©©©©©©©©©©©© 553 | Ivan Grishaev, 2025. © UNLICENSE © 554 | ©©©©©©©©©©©©©©©©©©©©©©©©©©©©©©©©©© 555 | ~~~ 556 | -------------------------------------------------------------------------------- /cljfmt.edn: -------------------------------------------------------------------------------- 1 | {:remove-consecutive-blank-lines? false 2 | :paths ["src" "test"]} 3 | -------------------------------------------------------------------------------- /env/demo1/src/demo1/main.clj: -------------------------------------------------------------------------------- 1 | (ns demo1.main 2 | (:gen-class) 3 | (:require 4 | [lambda.main :as main] 5 | [lambda.ring :as ring])) 6 | 7 | 8 | (defn handler [request] 9 | 10 | (let [{:keys [request-method 11 | uri 12 | headers 13 | body]} 14 | request] 15 | 16 | {:status 200 17 | :body {:request request}})) 18 | 19 | 20 | (def fn-event 21 | (-> handler 22 | (ring/wrap-json-body) 23 | (ring/wrap-json-response) 24 | (ring/wrap-gzip) 25 | (ring/wrap-ring-exception) 26 | (ring/wrap-ring-event))) 27 | 28 | 29 | (defn -main [& _] 30 | (main/run fn-event)) 31 | -------------------------------------------------------------------------------- /env/demo2/src/demo2/main.clj: -------------------------------------------------------------------------------- 1 | (ns demo2.main 2 | (:require 3 | [lambda.log :as log] 4 | [lambda.main :as main]) 5 | (:gen-class)) 6 | 7 | 8 | (defn process-event [db event] 9 | (jdbc/with-transaction [tx db] 10 | (jdbc/insert! tx ...) 11 | (jdbc/delete! tx ...))) 12 | 13 | 14 | (defn make-handler [] 15 | 16 | (let [config 17 | (-> "config.edn" 18 | io/resource 19 | aero/read-config) 20 | 21 | db 22 | (jdbc/get-connection (:db config))] 23 | 24 | (fn [event] 25 | (process-event db event)))) 26 | 27 | 28 | (defn -main [& _] 29 | (let [handler (make-handler)] 30 | (main/run handler))) 31 | -------------------------------------------------------------------------------- /env/demo3/src/demo3/main.clj: -------------------------------------------------------------------------------- 1 | (ns demo3.main 2 | (:gen-class) 3 | (:require 4 | [com.stuartsierra.component :as component] 5 | [lambda.component :as lc] 6 | [lambda.main :as main] 7 | [lambda.ring :as ring])) 8 | 9 | 10 | (defprotocol ICounter 11 | (-inc-page [this uri]) 12 | (-get-page [this uri]) 13 | (-stats [this])) 14 | 15 | 16 | (defn new-counter [] 17 | (let [-state (atom {})] 18 | (reify ICounter 19 | (-inc-page [this uri] 20 | (swap! -state update uri (fnil inc 0)) 21 | this) 22 | (-get-page [this uri] 23 | (get @-state uri 0)) 24 | (-stats [this] 25 | @-state)))) 26 | 27 | 28 | (def HELP " 29 | Hi! 30 | 31 | This is my lambda function for public tests. It's written in Clojure and 32 | compiled with native image. The output binary file is run in bare AWS 33 | environment (language-agnostic). The file communicates with AWS Lambda runtime 34 | directly. 35 | 36 | The Lambda uses Stuart Sierra's Component library and a Ring middleware that 37 | turns AWS messages into Ring-compatible maps. See the source code of this demo: 38 | 39 | https://github.com/igrishaev/lambda/blob/master/env/demo3/src/demo3/main.clj 40 | 41 | This is what it can do: 42 | 43 | - GET / 44 | Show this message; 45 | 46 | - GET /stats 47 | Return statistics about how many times pages were seen; 48 | 49 | - GET / 50 | Increase in-memory counter for the current page. 51 | 52 | You can benchmark this lambda as follows to measure RPS: 53 | 54 | ab -n 1000 -c 200 -l https://kpryignyuxqx3wwuss7oqvox7q0yhili.lambda-url.us-east-1.on.aws/ 55 | 56 | ~Ivan 57 | ") 58 | 59 | 60 | (defn handler-info [request] 61 | {:status 200 62 | :body HELP 63 | :headers {"content-type" "text/plain"}}) 64 | 65 | 66 | (defn handler-stats [request counter] 67 | {:status 200 68 | :body {:stats (-stats counter)}}) 69 | 70 | 71 | (defn response-default [request counter] 72 | (let [{:keys [uri]} 73 | request 74 | 75 | times 76 | (-> counter 77 | (-inc-page uri) 78 | (-get-page uri))] 79 | 80 | {:status 200 81 | :body {:times times 82 | :uri uri}})) 83 | 84 | 85 | (defn make-handler [counter] 86 | (fn [request] 87 | (let [{:keys [uri request-method]} 88 | request] 89 | (case [request-method uri] 90 | 91 | [:get "/"] 92 | (handler-info request) 93 | 94 | [:get "/stats"] 95 | (handler-stats request counter) 96 | 97 | (response-default request counter))))) 98 | 99 | 100 | (defrecord RingHandler [counter] 101 | component/Lifecycle 102 | (start [this] 103 | (-> (make-handler counter) 104 | (ring/wrap-json-body) 105 | (ring/wrap-json-response) 106 | (ring/wrap-gzip) 107 | (ring/wrap-ring-exception) 108 | (ring/wrap-ring-event)))) 109 | 110 | 111 | ;; 112 | ;; Must be a function but not a top-level static variable. 113 | ;; Otherwise, you'll get weird behaviour with native-image. 114 | ;; 115 | 116 | (defn make-system [] 117 | (component/system-map 118 | 119 | :counter 120 | (new-counter) 121 | 122 | :handler 123 | (-> {} 124 | (map->RingHandler) 125 | (component/using [:counter])) 126 | 127 | :lambda 128 | (-> (lc/lambda) 129 | (component/using [:handler])))) 130 | 131 | 132 | (defn -main [& _] 133 | (-> (make-system) 134 | (component/start))) 135 | -------------------------------------------------------------------------------- /env/dev/resources/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "routeKey": "$default", 4 | "rawPath": "/my/path", 5 | "rawQueryString": "parameter1=value1¶meter1=value2¶meter2=value", 6 | "cookies": [ 7 | "cookie1", 8 | "cookie2" 9 | ], 10 | "headers": { 11 | "Content-Type": "application/json", 12 | "header2": "value1,value2" 13 | }, 14 | "queryStringParameters": { 15 | "parameter1": "value1,value2", 16 | "parameter2": "value" 17 | }, 18 | "requestContext": { 19 | "accountId": "123456789012", 20 | "apiId": "", 21 | "authentication": null, 22 | "authorizer": { 23 | "iam": { 24 | "accessKey": "AKIA...", 25 | "accountId": "111122223333", 26 | "callerId": "AIDA...", 27 | "cognitoIdentity": null, 28 | "principalOrgId": null, 29 | "userArn": "arn:aws:iam::111122223333:user/example-user", 30 | "userId": "AIDA..." 31 | } 32 | }, 33 | "domainName": ".lambda-url.us-west-2.on.aws", 34 | "domainPrefix": "", 35 | "http": { 36 | "method": "POST", 37 | "path": "/my/path", 38 | "protocol": "HTTP/1.1", 39 | "sourceIp": "123.123.123.123", 40 | "userAgent": "agent" 41 | }, 42 | "requestId": "id", 43 | "routeKey": "$default", 44 | "stage": "$default", 45 | "time": "12/Mar/2020:19:03:58 +0000", 46 | "timeEpoch": 1583348638390 47 | }, 48 | "body": "{\"foo\":123}", 49 | "pathParameters": null, 50 | "isBase64Encoded": false, 51 | "stageVariables": null 52 | } 53 | -------------------------------------------------------------------------------- /links.md: -------------------------------------------------------------------------------- 1 | # AWS Lambda Python Runtime Interface Client 2 | https://github.com/aws/aws-lambda-python-runtime-interface-client 3 | 4 | # Using the Lambda runtime API for custom runtimes 5 | https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html 6 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject com.github.igrishaev/lambda "0.1.7-SNAPSHOT" 2 | 3 | :description 4 | "AWS Lambda as single binary file" 5 | 6 | :url 7 | "https://github.com/igrishaev/lambda" 8 | 9 | :license 10 | {:name "The Unlicense" 11 | :url "https://unlicense.org/"} 12 | 13 | :deploy-repositories 14 | {"releases" 15 | {:url "https://repo.clojars.org" 16 | :creds :gpg}} 17 | 18 | :release-tasks 19 | [["vcs" "assert-committed"] 20 | ["change" "version" "leiningen.release/bump-version" "release"] 21 | ["vcs" "commit"] 22 | ["vcs" "tag" "--no-sign"] 23 | ["deploy"] 24 | ["change" "version" "leiningen.release/bump-version"] 25 | ["vcs" "commit"] 26 | ["vcs" "push"]] 27 | 28 | :managed-dependencies 29 | [[org.clojure/clojure "1.11.1"] 30 | [org.babashka/http-client "0.4.22"] 31 | [com.github.igrishaev/jsam "0.1.0"] 32 | [ring/ring-core "1.9.6"] 33 | [com.stuartsierra/component "1.1.0"]] 34 | 35 | :dependencies 36 | [[org.clojure/clojure :scope "provided"] 37 | [org.babashka/http-client] 38 | [com.github.igrishaev/jsam]] 39 | 40 | :target-path 41 | "target/uberjar" 42 | 43 | :uberjar-name 44 | "bootstrap.jar" 45 | 46 | :profiles 47 | {:demo1 48 | {:main demo1.main 49 | :source-paths ["env/demo1/src"]} 50 | 51 | :demo3 52 | {:main demo3.main 53 | :source-paths ["env/demo3/src"] 54 | :dependencies [[com.stuartsierra/component]] 55 | :resource-paths ["env/dev/resources"]} 56 | 57 | :dev 58 | {:dependencies [[ring/ring-core] 59 | [com.stuartsierra/component]] 60 | :resource-paths ["env/dev/resources"]} 61 | 62 | :uberjar 63 | {:aot :all 64 | :jvm-opts ["-Dclojure.compiler.direct-linking=true"]}}) 65 | -------------------------------------------------------------------------------- /src/lambda/api.clj: -------------------------------------------------------------------------------- 1 | ;; https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html 2 | 3 | (ns lambda.api 4 | (:require 5 | [babashka.http-client :as http] 6 | [clojure.string :as str] 7 | [jsam.core :as jsam] 8 | [lambda.config :as config] 9 | [lambda.error :as e])) 10 | 11 | (defn parse-response [response] 12 | (update response 13 | :body 14 | (fn [body] 15 | (some-> body jsam/read)))) 16 | 17 | 18 | (defn api-call 19 | ([method path] 20 | (api-call method path nil)) 21 | 22 | ([method path data] 23 | (api-call method path data nil)) 24 | 25 | ([method path data headers] 26 | 27 | (let [host 28 | #_:clj-kondo/ignore (config/host) 29 | 30 | uri 31 | (format "http://%s/%s%s" 32 | host 33 | #_:clj-kondo/ignore (config/version) 34 | path) 35 | 36 | request 37 | {:uri uri 38 | :method method 39 | :headers headers 40 | :as :stream 41 | :timeout #_:clj-kondo/ignore (config/timeout) 42 | :body (when data 43 | (jsam/write-string data))}] 44 | 45 | (try 46 | (-> request 47 | (http/request) 48 | (parse-response)) 49 | (catch Exception e 50 | (e/throw! "Failed to interact with the Runtime API, method: %s, url: %s, status: %s, message: %s" 51 | method 52 | uri 53 | (some-> e ex-data :status) 54 | (ex-message e))))))) 55 | 56 | 57 | (defn next-invocation [] 58 | (api-call :get "/runtime/invocation/next")) 59 | 60 | 61 | (defn invocation-response [^String request-id data] 62 | (let [path 63 | (format "/runtime/invocation/%s/response" request-id)] 64 | (api-call :post path data))) 65 | 66 | 67 | (defn ex->ErrorRequest [e] 68 | 69 | (let [{:keys [via 70 | trace]} 71 | (Throwable->map e) 72 | 73 | stackTrace 74 | (for [el trace] 75 | (str/join \space el)) 76 | 77 | errorType 78 | (-> via first :type) 79 | 80 | errorMessage 81 | (-> via first :message)] 82 | 83 | {:errorMessage errorMessage 84 | :errorType errorType 85 | :stackTrace stackTrace})) 86 | 87 | 88 | (defn add-error-type [headers ^String error-type] 89 | (cond-> headers 90 | error-type 91 | (assoc "Lambda-Runtime-Function-Error-Type" error-type))) 92 | 93 | 94 | (defn init-error 95 | [e] 96 | (let [path 97 | "/runtime/init/error" 98 | 99 | data 100 | (ex->ErrorRequest e) 101 | 102 | headers 103 | {"Lambda-Runtime-Function-Error-Type" 104 | "Runtime.UnknownReason"}] 105 | 106 | (api-call :post path data headers))) 107 | 108 | 109 | (defn invocation-error 110 | [request-id e] 111 | (let [path 112 | (format "/runtime/invocation/%s/error" request-id) 113 | 114 | data 115 | (ex->ErrorRequest e) 116 | 117 | headers 118 | {"Lambda-Runtime-Function-Error-Type" 119 | "Runtime.UnknownReason"}] 120 | 121 | (api-call :post path data headers))) 122 | -------------------------------------------------------------------------------- /src/lambda/codec.clj: -------------------------------------------------------------------------------- 1 | (ns lambda.codec 2 | (:import 3 | (java.io InputStream 4 | ByteArrayOutputStream 5 | ByteArrayInputStream) 6 | (java.util Base64) 7 | (java.util.zip GZIPOutputStream 8 | GZIPInputStream))) 9 | 10 | 11 | (defn b64-decode ^bytes [^bytes input] 12 | (.decode (Base64/getDecoder) input)) 13 | 14 | 15 | (defn b64-encode ^bytes [^bytes input] 16 | (.encode (Base64/getEncoder) input)) 17 | 18 | 19 | (defn bytes->str 20 | (^String [^bytes input] 21 | (new String input)) 22 | 23 | (^String [^bytes input ^String encoding] 24 | (new String input encoding))) 25 | 26 | 27 | (defn str->bytes 28 | (^bytes [^String input] 29 | (.getBytes input)) 30 | 31 | (^bytes [^String input ^String encoding] 32 | (.getBytes input encoding))) 33 | 34 | 35 | (defn bytes->gzip ^bytes [^bytes ba] 36 | (let [baos (new ByteArrayOutputStream)] 37 | (with-open [out (new GZIPOutputStream baos) 38 | in (new ByteArrayInputStream ba)] 39 | (.transferTo in out)) 40 | (.toByteArray baos))) 41 | 42 | 43 | (defn gzip->bytes ^bytes [^bytes ba] 44 | (let [baos (new ByteArrayOutputStream)] 45 | (with-open [in (new GZIPInputStream (new ByteArrayInputStream ba))] 46 | (.transferTo in baos)) 47 | (.toByteArray baos))) 48 | 49 | 50 | (defn read-bytes ^bytes [^InputStream in] 51 | (.readAllBytes in)) 52 | -------------------------------------------------------------------------------- /src/lambda/component.clj: -------------------------------------------------------------------------------- 1 | (ns lambda.component 2 | " 3 | Stuart Sierra's Component integration. Provides a function to build 4 | a component that processes Lambda messages in a separate thread. 5 | " 6 | (:import 7 | (clojure.lang IFn) 8 | (java.io Writer)) 9 | (:require 10 | [lambda.log :as log] 11 | [lambda.main :as main])) 12 | 13 | 14 | (set! *warn-on-reflection* true) 15 | 16 | 17 | ;; 18 | ;; Here and below: we mimic the Component protocol and extend 19 | ;; the component via metadata to avoid Component dependency. 20 | ;; 21 | (defprotocol Lifecycle 22 | (start [this]) 23 | (stop [this])) 24 | 25 | 26 | (defrecord LambdaHandler [;; deps 27 | ^IFn handler 28 | 29 | ;; runtime 30 | ^Thread -thread] 31 | 32 | Lifecycle 33 | 34 | (start [this] 35 | (if -thread 36 | this 37 | (let [-thread (main/run-thread handler)] 38 | (log/infof "lambda handler thread started") 39 | (assoc this :-thread -thread)))) 40 | 41 | (stop [this] 42 | (if -thread 43 | (do 44 | (.interrupt -thread) 45 | (log/infof "lambda handler thread interrupted") 46 | (.join -thread) 47 | (log/infof "lambda handler thread joined") 48 | (assoc this :-thread nil)) 49 | this)) 50 | 51 | Object 52 | 53 | (toString [_] 54 | (format "" 55 | (pr-str handler) -thread))) 56 | 57 | 58 | (defmethod print-method LambdaHandler 59 | [x ^Writer w] 60 | (.write w (str x))) 61 | 62 | 63 | (defn with-component-meta 64 | " 65 | Extend via metadata to not depend on the component library. 66 | " 67 | [component] 68 | (with-meta component 69 | {'com.stuartsierra.component/start start 70 | 'com.stuartsierra.component/stop stop})) 71 | 72 | 73 | (defn lambda 74 | " 75 | Make a component that, when started, runs an endless AWS message 76 | processing loop in a separate thread. The stop action interrupts 77 | the thread and joins it. 78 | " 79 | ([] 80 | (-> nil 81 | (map->LambdaHandler) 82 | (with-component-meta))) 83 | 84 | ([handler] 85 | (-> {:handler handler} 86 | (map->LambdaHandler) 87 | (with-component-meta)))) 88 | -------------------------------------------------------------------------------- /src/lambda/config.clj: -------------------------------------------------------------------------------- 1 | (ns lambda.config 2 | " 3 | A namespace that serves as a config object. 4 | Each property is a no-arg function which gets 5 | cached. Cannot declare these as static variables 6 | on top of the namespace because GraalVM freezes 7 | runtime (and the env map as well). 8 | ") 9 | 10 | (defmacro defprop [name & body] 11 | `(def ~name 12 | (memoize 13 | (fn [] 14 | ~@body)))) 15 | 16 | #_:clj-kondo/ignore 17 | (defprop timeout 18 | (or (some-> "LAMBDA_RUNTIME_TIMEOUT" System/getenv parse-long) 19 | (* 15 60 1000))) 20 | 21 | #_:clj-kondo/ignore 22 | (defprop version 23 | (or (some-> "LAMBDA_RUNTIME_VERSION" System/getenv) 24 | "2018-06-01")) 25 | 26 | #_:clj-kondo/ignore 27 | (defprop host 28 | (System/getenv "AWS_LAMBDA_RUNTIME_API")) 29 | 30 | 31 | #_:clj-kondo/ignore 32 | (defprop gzip? 33 | (System/getenv "AWS_LAMBDA_USE_GZIP")) 34 | -------------------------------------------------------------------------------- /src/lambda/error.clj: -------------------------------------------------------------------------------- 1 | (ns lambda.error) 2 | 3 | (defmacro throw! [template & args] 4 | `(throw (new RuntimeException (format ~template ~@args)))) 5 | 6 | (defmacro rethrow! [e template & args] 7 | `(throw (new RuntimeException 8 | (format ~template ~@args) 9 | ~e))) 10 | 11 | (defmacro with-safe [& body] 12 | `(try 13 | (let [result# (do ~@body)] 14 | [nil result#]) 15 | (catch Throwable e# 16 | [e# nil]))) 17 | -------------------------------------------------------------------------------- /src/lambda/log.clj: -------------------------------------------------------------------------------- 1 | (ns lambda.log 2 | (:require 3 | [clojure.stacktrace :as st] 4 | [clojure.string :as str])) 5 | 6 | 7 | (defmacro logf [level template & args] 8 | (let [nsn (ns-name *ns*)] 9 | `(println '~nsn 10 | (-> ~level name str/upper-case) 11 | (format ~template ~@args)))) 12 | 13 | 14 | (defmacro debugf [template & args] 15 | `(logf :debug ~template ~@args)) 16 | 17 | 18 | (defmacro infof [template & args] 19 | `(logf :info ~template ~@args)) 20 | 21 | 22 | (defmacro errorf [template & args] 23 | `(logf :error ~template ~@args)) 24 | 25 | (defmacro exception [e] 26 | `(logf :error (with-out-str 27 | (st/print-stack-trace ~e)))) 28 | -------------------------------------------------------------------------------- /src/lambda/main.clj: -------------------------------------------------------------------------------- 1 | ;; https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html 2 | ;; https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html 3 | ;; https://docs.aws.amazon.com/lambda/latest/dg/urls-invocation.html 4 | 5 | (ns lambda.main 6 | (:require 7 | [lambda.log :as log] 8 | [lambda.api :as api] 9 | [lambda.error :as e])) 10 | 11 | (set! *warn-on-reflection* true) 12 | 13 | 14 | (defn step [fn-event] 15 | (let [{:keys [headers body]} 16 | (api/next-invocation) 17 | 18 | request-id 19 | (get headers "lambda-runtime-aws-request-id") 20 | 21 | [e response] 22 | (e/with-safe 23 | (fn-event body))] 24 | 25 | (if e 26 | (do 27 | (log/errorf "Event error, request ID: %s" request-id) 28 | (log/exception e) 29 | (api/invocation-error request-id e)) 30 | (api/invocation-response request-id response)))) 31 | 32 | 33 | (defn run 34 | " 35 | Run an endless event loop in the current thread. 36 | " 37 | [fn-event] 38 | (while true 39 | (step fn-event))) 40 | 41 | 42 | (defn run-thread 43 | " 44 | Run an endless event loop in a new thread. Returns 45 | a Thread instance. To stop the loop, interrupt the 46 | thread and then join it. 47 | " 48 | ^Thread [fn-event] 49 | (let [thread 50 | (new Thread 51 | (fn [] 52 | (while (not (Thread/interrupted)) 53 | (step fn-event))))] 54 | (.start thread) 55 | thread)) 56 | -------------------------------------------------------------------------------- /src/lambda/ring.clj: -------------------------------------------------------------------------------- 1 | ;; https://github.com/ring-clojure/ring/blob/master/SPEC 2 | 3 | (ns lambda.ring 4 | " 5 | A namespace to mimic Ring functionality, namely: 6 | - turn Lambda HTTP events into Ring maps and back; 7 | - provide custom Ring middleware. 8 | " 9 | (:require 10 | [clojure.java.io :as io] 11 | [clojure.string :as str] 12 | [jsam.core :as jsam] 13 | [lambda.codec :as codec] 14 | [lambda.config :as config] 15 | [lambda.error :refer [throw! 16 | with-safe]] 17 | [lambda.log :as log]) 18 | (:import 19 | (clojure.lang ISeq) 20 | (java.io File 21 | InputStream) 22 | (java.util.zip GZIPInputStream))) 23 | 24 | 25 | (defn process-headers 26 | " 27 | Turn Lambda headers into a Ring headers map. 28 | " 29 | [headers] 30 | (persistent! 31 | (reduce-kv 32 | (fn [acc! k v] 33 | (let [h (-> k name str/lower-case)] 34 | (assoc! acc! h v))) 35 | (transient {}) 36 | headers))) 37 | 38 | 39 | (defn ->ring 40 | " 41 | Turn Lambda event into a Ring map. 42 | " 43 | [event] 44 | 45 | (let [{:keys [headers 46 | isBase64Encoded 47 | rawQueryString 48 | queryStringParameters 49 | requestContext 50 | body]} 51 | event 52 | 53 | {:keys [http 54 | requestId]} 55 | requestContext 56 | 57 | {:keys [method 58 | path 59 | protocol 60 | sourceIp 61 | userAgent]} 62 | http 63 | 64 | stream 65 | (when body 66 | (if isBase64Encoded 67 | (-> body 68 | codec/str->bytes 69 | codec/b64-decode 70 | io/input-stream) 71 | (-> body 72 | codec/str->bytes 73 | io/input-stream))) 74 | 75 | request-method 76 | (some-> method str/lower-case keyword) 77 | 78 | norm-headers 79 | (-> headers 80 | (process-headers) 81 | (assoc "x-request-id" requestId)) 82 | 83 | request 84 | {:remote-addr sourceIp 85 | :uri path 86 | :query-params queryStringParameters 87 | :query-string rawQueryString 88 | :request-method request-method 89 | :protocol protocol 90 | :user-agent userAgent 91 | :headers norm-headers 92 | :body stream}] 93 | 94 | (with-meta request {:event event}))) 95 | 96 | 97 | (def TYPE_ARR_BYTE 98 | (Class/forName "[B")) 99 | 100 | 101 | ;; A protocol to coerse various Ring responses 102 | ;; to a Lambda response. Must return a couple of 103 | ;; [is-base64-encoded?, string]. 104 | (defprotocol IBody 105 | (->body [this])) 106 | 107 | 108 | (extend-protocol IBody 109 | 110 | nil 111 | (->body [_] 112 | [false ""]) 113 | 114 | Object 115 | (->body [this] 116 | (throw! "Cannot coerce %s to response body" this)) 117 | 118 | String 119 | (->body [this] 120 | [false this]) 121 | 122 | ISeq 123 | (->body [this] 124 | (->body (apply str this))) 125 | 126 | File 127 | (->body [this] 128 | (->body (io/input-stream this))) 129 | 130 | InputStream 131 | (->body [this] 132 | (->body (codec/read-bytes this)))) 133 | 134 | 135 | ;; Arrays can be extended like this only 136 | (extend TYPE_ARR_BYTE 137 | IBody 138 | {:->body 139 | (fn [this] 140 | [true (-> this 141 | codec/b64-encode 142 | codec/bytes->str)])}) 143 | 144 | 145 | (defn ring-> 146 | " 147 | Turn a Ring response map into a Lambda HTTP response. 148 | " 149 | [response] 150 | 151 | (let [{:keys [status 152 | headers 153 | body]} 154 | response 155 | 156 | [b64-encoded? string] 157 | (->body body)] 158 | 159 | {:statusCode status 160 | :headers headers 161 | :isBase64Encoded b64-encoded? 162 | :body string})) 163 | 164 | 165 | (defn wrap-ring-event 166 | " 167 | A ring middleware that transforms an HTTP Lambda 168 | event into a Ring request, processes it with a 169 | Ring handler, and turns the result into a Lambda 170 | HTTP response. 171 | " 172 | [handler] 173 | (fn [event] 174 | (-> event 175 | (->ring) 176 | (handler) 177 | (ring->)))) 178 | 179 | 180 | (def response-internal-error 181 | {:status 500 182 | :headers {"content-type" "text/plain"} 183 | :body "Internal server error"}) 184 | 185 | 186 | (defn wrap-ring-exception 187 | " 188 | A middleware what captures any Ring exceptions, 189 | logs them and returns a negative HTTP response. 190 | " 191 | [handler] 192 | (fn [request] 193 | (try 194 | (handler request) 195 | (catch Throwable e 196 | (let [{:keys [uri 197 | request-method]} 198 | request] 199 | (log/errorf "Unhandled exception in a Ring handler, method: %s, uri: %s" 200 | request-method uri) 201 | (log/exception e) 202 | response-internal-error))))) 203 | 204 | 205 | ;; 206 | ;; JSON middleware 207 | ;; 208 | 209 | (defn json-request? 210 | " 211 | Check if the Ring request was of a JSON type. 212 | " 213 | [request] 214 | (when-let [content-type 215 | (get-in request [:headers "content-type"])] 216 | (re-find #"^(?i)application/(.+\+)?json" content-type))) 217 | 218 | 219 | (def response-json-malformed 220 | {:status 400 221 | :headers {"content-type" "text/plain"} 222 | :body "Malformed JSON payload"}) 223 | 224 | 225 | (defn wrap-json-body 226 | " 227 | A middleware that, if the request was JSON, 228 | replaces the :body field with the parsed JSON 229 | data. Takes an optional map of Jsam settings. 230 | " 231 | ([handler] 232 | (wrap-json-body handler nil)) 233 | 234 | ([handler opt] 235 | (fn [request] 236 | (if (json-request? request) 237 | (let [[e request-json] 238 | (with-safe 239 | (update request :body jsam/read opt))] 240 | (if e 241 | response-json-malformed 242 | (handler request-json))) 243 | (handler request))))) 244 | 245 | 246 | (defn assoc-json-params [request json] 247 | (if (map? json) 248 | (-> request 249 | (assoc :json-params json) 250 | (update-in [:params] merge json)) 251 | request)) 252 | 253 | 254 | (defn wrap-json-params 255 | " 256 | A middleware that, if the request was JSON, 257 | adds the :json-params field to the request, 258 | and also merged then with :params, if the 259 | data was a map. Takes an optional map of 260 | Jsam settings. 261 | " 262 | ([handler] 263 | (wrap-json-params handler nil)) 264 | 265 | ([handler opt] 266 | (fn [request] 267 | (if (json-request? request) 268 | (let [[e data] 269 | (with-safe 270 | (some-> request :body (jsam/read opt)))] 271 | (if e 272 | response-json-malformed 273 | (-> request 274 | (assoc-json-params data) 275 | (handler)))) 276 | (handler request))))) 277 | 278 | 279 | (def CONTENT-TYPE-JSON 280 | "application/json; charset=utf-8") 281 | 282 | 283 | (defn wrap-json-response 284 | " 285 | A middleware that, if the body of the response 286 | was a collection, transforms the body into 287 | a JSON string and adds a Content-Type header 288 | with JSON mime-type. Takes an optional map of 289 | Jsam settings. 290 | " 291 | ([handler] 292 | (wrap-json-response handler nil)) 293 | 294 | ([handler opt] 295 | (fn [request] 296 | (let [response (handler request)] 297 | (if (-> response :body coll?) 298 | (-> response 299 | (update :body jsam/write-string opt) 300 | (assoc-in [:headers "content-type"] CONTENT-TYPE-JSON)) 301 | response))))) 302 | 303 | 304 | ;; 305 | ;; GZip middleware 306 | ;; 307 | 308 | (defn accept-gzip? [request] 309 | (or #_:clj-kondo/ignore (config/gzip?) 310 | (some-> request 311 | :headers 312 | (get "accept-encoding") 313 | (str/includes? "gzip")))) 314 | 315 | 316 | (defn encoded-gzip? [request] 317 | (some-> request 318 | :headers 319 | (get "content-encoding") 320 | (str/includes? "gzip"))) 321 | 322 | 323 | (defprotocol IGzip 324 | (-gzip-encode [this])) 325 | 326 | 327 | (extend-protocol IGzip 328 | 329 | nil 330 | (-gzip-encode [_] 331 | nil) 332 | 333 | Object 334 | (-gzip-encode [this] 335 | (throw! "Cannot gzip-encode body: %s" this)) 336 | 337 | String 338 | (-gzip-encode [this] 339 | (-> this 340 | codec/str->bytes 341 | codec/bytes->gzip)) 342 | 343 | ISeq 344 | (-gzip-encode [this] 345 | (-gzip-encode (apply str this))) 346 | 347 | File 348 | (-gzip-encode [this] 349 | (-gzip-encode (io/input-stream this))) 350 | 351 | InputStream 352 | (-gzip-encode [this] 353 | (-> this 354 | codec/read-bytes 355 | codec/bytes->gzip))) 356 | 357 | 358 | (extend TYPE_ARR_BYTE 359 | IGzip 360 | {:-gzip-encode 361 | (fn [this] 362 | (codec/bytes->gzip this))}) 363 | 364 | 365 | (defn gzip-response 366 | " 367 | Gzip-encode a Ring response in two steps: 368 | - assoc a header; 369 | - encode the body payload. 370 | " 371 | [response] 372 | (-> response 373 | (assoc-in [:headers "content-encoding"] "gzip") 374 | (update :body -gzip-encode))) 375 | 376 | 377 | (defn ungzip-request 378 | " 379 | Wrap the request's :body field with a class 380 | that decodes gzip payload on the fly. 381 | " 382 | [request] 383 | (update request 384 | :body 385 | (fn [input-stream] 386 | (new GZIPInputStream input-stream)))) 387 | 388 | 389 | (defn wrap-gzip 390 | " 391 | Wrap a given handler with in/out gzip logic. 392 | 393 | If a client sends a header Content-Encoding: gzip, 394 | then the :body of the request is wrapped into an 395 | instance of GzipInputStream. 396 | 397 | If a client sends a header Accept-Encoding: gzip, 398 | then the :body of the response is encoded into 399 | a Gzipped byte array. In addition, the response 400 | gets a header Content-Encoding: gzip. Supported 401 | body types are: nil, String, native byte-array, 402 | ISeq(of String), and InputStream. 403 | 404 | Gzip output encoding might be forced with a global 405 | configuration (see the lambda.config namespace). 406 | " 407 | [handler] 408 | (fn [request] 409 | 410 | (let [encoded? 411 | (encoded-gzip? request) 412 | 413 | accept? 414 | (accept-gzip? request)] 415 | 416 | (cond-> request 417 | 418 | encoded? 419 | (ungzip-request) 420 | 421 | :then 422 | (handler) 423 | 424 | accept? 425 | (gzip-response))))) 426 | -------------------------------------------------------------------------------- /test/lambda/component_test.clj: -------------------------------------------------------------------------------- 1 | (ns lambda.component-test 2 | (:require 3 | [clojure.string :as str] 4 | [clojure.test :refer [deftest is]] 5 | [com.stuartsierra.component :as component] 6 | [lambda.component :as lc])) 7 | 8 | 9 | (deftest test-component-ok 10 | (let [c (lc/lambda +)] 11 | (is (satisfies? component/Lifecycle c)) 12 | (is (-> c 13 | str 14 | (str/starts-with? " c 16 | pr-str 17 | (str/starts-with? " handler 34 | (wrap-keyword-params) 35 | (wrap-params) 36 | (ring/wrap-json-body) 37 | (ring/wrap-json-response) 38 | (ring/wrap-ring-event))) 39 | 40 | 41 | (deftest test-response 42 | 43 | (let [event 44 | (-> "event.json" 45 | (io/resource) 46 | (jsam/read)) 47 | 48 | response 49 | (fn-event event)] 50 | 51 | (is (= {:statusCode 200, 52 | :headers {"content-type" "application/json; charset=utf-8"}, 53 | :isBase64Encoded false, 54 | :body "{\"aaa\":1}"} 55 | response)))) 56 | 57 | 58 | #_:clj-kondo/ignore 59 | (deftest test-request 60 | 61 | (let [event 62 | (-> "event.json" 63 | (io/resource) 64 | (jsam/read)) 65 | 66 | response 67 | (fn-event event) 68 | 69 | request 70 | @capture!] 71 | 72 | (is (= {:user-agent "agent", 73 | :protocol "HTTP/1.1", 74 | :remote-addr "123.123.123.123", 75 | :params {}, 76 | :headers {"content-type" "application/json", 77 | "header2" "value1,value2" 78 | "x-request-id" "id"}, 79 | :form-params {}, 80 | :query-params {:parameter1 "value1,value2", :parameter2 "value"}, 81 | :uri "/my/path", 82 | :query-string "parameter1=value1¶meter1=value2¶meter2=value", 83 | :body {:foo 123} 84 | :request-method :post} 85 | 86 | request)))) 87 | 88 | 89 | #_:clj-kondo/ignore 90 | (deftest test-form-params 91 | 92 | (let [event 93 | (-> "event.json" 94 | (io/resource) 95 | (jsam/read) 96 | (assoc-in [:headers "Content-Type"] 97 | "application/x-www-form-urlencoded") 98 | (assoc-in [:body] 99 | "test=foo&hello=bar")) 100 | 101 | response 102 | (fn-event event) 103 | 104 | request 105 | @capture!] 106 | 107 | (is (= {:user-agent "agent", 108 | :protocol "HTTP/1.1", 109 | :remote-addr "123.123.123.123", 110 | :params {:test "foo", :hello "bar"}, 111 | :headers 112 | {"content-type" "application/x-www-form-urlencoded", 113 | "header2" "value1,value2" 114 | "x-request-id" "id"}, 115 | :form-params {"test" "foo", "hello" "bar"}, 116 | :query-params {:parameter1 "value1,value2", :parameter2 "value"}, 117 | :uri "/my/path", 118 | :query-string "parameter1=value1¶meter1=value2¶meter2=value", 119 | :request-method :post} 120 | 121 | (dissoc request :body))))) 122 | -------------------------------------------------------------------------------- /test/lambda/gzip_test.clj: -------------------------------------------------------------------------------- 1 | (ns lambda.gzip-test 2 | (:import 3 | (java.io File)) 4 | (:require 5 | [lambda.config :as config] 6 | [lambda.codec :as codec] 7 | [clojure.java.io :as io] 8 | [clojure.test :refer [is deftest testing]] 9 | [lambda.ring :as ring])) 10 | 11 | 12 | (deftest test-codec 13 | (is (= [31 -117 8 0 0 0 0 0 0 -1 43 73 45 46 1 0 12 126 127 -40 4 0 0 0] 14 | (-> "test" 15 | (codec/str->bytes) 16 | (codec/bytes->gzip) 17 | (vec)))) 18 | (is (= "test" 19 | (-> [31 -117 8 0 0 0 0 0 0 -1 43 73 45 46 1 0 12 126 127 -40 4 0 0 0] 20 | byte-array 21 | (codec/gzip->bytes) 22 | (codec/bytes->str))))) 23 | 24 | 25 | (deftest test-wrap-gzip-encode-response 26 | 27 | (testing "no req/resp header" 28 | (let [handler 29 | (ring/wrap-gzip 30 | (fn [_request] 31 | {:status 200 32 | :body "test"}))] 33 | (is (= {:status 200 :body "test"} 34 | (handler {}))))) 35 | 36 | (testing "override config" 37 | (let [handler 38 | (ring/wrap-gzip 39 | (fn [_request] 40 | {:status 200 41 | :body "test"}))] 42 | (is (= {:status 200 43 | :body [31 -117 8 0 0 0 0 0 0 -1 43 73 45 46 1 0 12 126 127 -40 4 0 0 0] 44 | :headers {"content-encoding" "gzip"}} 45 | #_:clj-kondo/ignore 46 | (with-redefs [config/gzip? (constantly true)] 47 | (-> {} 48 | (handler) 49 | (update :body vec))))))) 50 | 51 | (testing "request header, string" 52 | (let [handler 53 | (ring/wrap-gzip 54 | (fn [_request] 55 | {:status 200 56 | :body "test"}))] 57 | (is (= (-> {:status 200 58 | :body [31 -117 8 0 0 0 0 0 0 -1 43 73 45 46 1 0 12 126 127 -40 4 0 0 0] 59 | :headers {"content-encoding" "gzip"}} 60 | (update :body vec)) 61 | (-> {:headers {"accept-encoding" "gzip"}} 62 | (handler) 63 | (update :body vec)))))) 64 | 65 | (testing "request header, byte array" 66 | (let [handler 67 | (ring/wrap-gzip 68 | (fn [_request] 69 | {:status 200 70 | :body (byte-array [116, 101, 115, 116])}))] 71 | (is (= (-> {:status 200 72 | :body [31 -117 8 0 0 0 0 0 0 -1 43 73 45 46 1 0 12 126 127 -40 4 0 0 0] 73 | :headers {"content-encoding" "gzip"}} 74 | (update :body vec)) 75 | (-> {:headers {"accept-encoding" "gzip"}} 76 | (handler) 77 | (update :body vec)))))) 78 | 79 | (testing "request header, nil" 80 | (let [handler 81 | (ring/wrap-gzip 82 | (fn [_request] 83 | {:status 200 84 | :body nil}))] 85 | (is (= (-> {:status 200 86 | :body nil 87 | :headers {"content-encoding" "gzip"}}) 88 | (-> {:headers {"accept-encoding" "gzip"}} 89 | (handler)))))) 90 | 91 | (testing "request header, input-stream" 92 | (let [handler 93 | (ring/wrap-gzip 94 | (fn [_request] 95 | {:status 200 96 | :body (io/input-stream (byte-array [116, 101, 115, 116]))}))] 97 | (is (= (-> {:status 200 98 | :body [31 -117 8 0 0 0 0 0 0 -1 43 73 45 46 1 0 12 126 127 -40 4 0 0 0] 99 | :headers {"content-encoding" "gzip"}} 100 | (update :body vec)) 101 | (-> {:headers {"accept-encoding" "gzip"}} 102 | (handler) 103 | (update :body vec)))))) 104 | 105 | (testing "request header, file" 106 | (let [file 107 | (File/createTempFile "test" ".txt") 108 | 109 | _ (spit file "test") 110 | 111 | handler 112 | (ring/wrap-gzip 113 | (fn [_request] 114 | {:status 200 115 | :body file}))] 116 | (is (= (-> {:status 200 117 | :body [31 -117 8 0 0 0 0 0 0 -1 43 73 45 46 1 0 12 126 127 -40 4 0 0 0] 118 | :headers {"content-encoding" "gzip"}} 119 | (update :body vec)) 120 | (-> {:headers {"accept-encoding" "gzip"}} 121 | (handler) 122 | (update :body vec)))))) 123 | 124 | (testing "request header, coll of strings" 125 | (let [handler 126 | (ring/wrap-gzip 127 | (fn [_request] 128 | {:status 200 129 | :body (seq "test")}))] 130 | (is (= (-> {:status 200 131 | :body [31 -117 8 0 0 0 0 0 0 -1 43 73 45 46 1 0 12 126 127 -40 4 0 0 0] 132 | :headers {"content-encoding" "gzip"}} 133 | (update :body vec)) 134 | (-> {:headers {"accept-encoding" "gzip"}} 135 | (handler) 136 | (update :body vec))))))) 137 | 138 | 139 | (deftest test-wrap-gzip-decode-request 140 | 141 | (testing "body is not gzip-encoded" 142 | (let [handler 143 | (ring/wrap-gzip 144 | (fn [request] 145 | {:status 200 146 | :body (format "body: %s" (-> request :body slurp))}))] 147 | (is (= {:status 200 148 | :body "body: test"} 149 | (-> {:body (io/input-stream (.getBytes "test"))} 150 | (handler)))))) 151 | 152 | (testing "body is not gzip-encoded" 153 | (let [handler 154 | (ring/wrap-gzip 155 | (fn [request] 156 | {:status 200 157 | :body (format "body: %s" (-> request :body slurp))}))] 158 | (is (= {:status 200 159 | :body "body: test"} 160 | (-> {:body (io/input-stream (codec/bytes->gzip (.getBytes "test"))) 161 | :headers {"content-encoding" "gzip"}} 162 | (handler))))))) 163 | -------------------------------------------------------------------------------- /test/lambda/json_test.clj: -------------------------------------------------------------------------------- 1 | (ns lambda.json-test 2 | (:require 3 | [clojure.java.io :as io] 4 | [clojure.test :refer [is deftest]] 5 | [lambda.ring :as ring])) 6 | 7 | 8 | (deftest test-wrap-json-response 9 | 10 | (let [handler 11 | (ring/wrap-json-response 12 | (fn [_request] 13 | {:status 200 14 | :body {:foo 123} 15 | :headers {"hello" "test"}}))] 16 | 17 | (is (= {:status 200, 18 | :body "{\"foo\":123}", 19 | :headers 20 | {"hello" "test", 21 | "content-type" "application/json; charset=utf-8"}} 22 | (handler {:method :get})))) 23 | 24 | (let [handler 25 | (ring/wrap-json-response 26 | (fn [_request] 27 | {:status 200 28 | :body "dunno" 29 | :headers {"hello" "test"}}))] 30 | 31 | (is (= {:status 200 32 | :body "dunno" 33 | :headers {"hello" "test"}} 34 | (handler {:method :get}))))) 35 | 36 | 37 | (defn ->stream [^String string] 38 | (-> string 39 | .getBytes 40 | io/input-stream)) 41 | 42 | 43 | (deftest test-wrap-json-body 44 | 45 | (let [handler 46 | (ring/wrap-json-body 47 | (fn [request] request))] 48 | (is (= {:body "[1, 2, 3]"} 49 | (handler {:body "[1, 2, 3]"})))) 50 | 51 | (let [handler 52 | (ring/wrap-json-body 53 | (fn [request] request))] 54 | (is (= {:body [1 2 3] 55 | :headers {"content-type" "application/json"}} 56 | (handler {:body (->stream "[1, 2, 3]") 57 | :headers {"content-type" "application/json"}})))) 58 | 59 | (let [handler 60 | (ring/wrap-json-body 61 | (fn [request] request))] 62 | (is (= {:status 400 63 | :headers {"content-type" "text/plain"} 64 | :body "Malformed JSON payload"} 65 | (handler {:body (->stream "dunno-lol") 66 | :headers {"content-type" "application/json"}}))))) 67 | 68 | 69 | (deftest test-wrap-json-params 70 | 71 | (let [handler 72 | (ring/wrap-json-params 73 | (fn [request] request))] 74 | (is (= {:body "[1, 2, 3]"} 75 | (handler {:body "[1, 2, 3]"})))) 76 | 77 | (let [handler 78 | (ring/wrap-json-params 79 | (fn [request] request))] 80 | (is (= {:headers {"content-type" "application/json"}} 81 | (-> {:body (->stream "[1, 2, 3]") 82 | :headers {"content-type" "application/json"}} 83 | (handler) 84 | (dissoc :body))))) 85 | 86 | (let [handler 87 | (ring/wrap-json-params 88 | (fn [request] request))] 89 | (is (= {:headers {"content-type" "application/json"} 90 | :json-params {:foo 123} 91 | :params {:foo 123}} 92 | (-> {:body (->stream "{\"foo\": 123}") 93 | :headers {"content-type" "application/json"}} 94 | (handler) 95 | (dissoc :body))))) 96 | 97 | (let [handler 98 | (ring/wrap-json-params 99 | (fn [request] request) 100 | {:fn-key identity})] 101 | (is (= {:headers {"content-type" "application/json"} 102 | :json-params {"foo" 123} 103 | :params {"foo" 123}} 104 | (-> {:body (->stream "{\"foo\": 123}") 105 | :headers {"content-type" "application/json"}} 106 | (handler) 107 | (dissoc :body))))) 108 | 109 | (let [handler 110 | (ring/wrap-json-params 111 | (fn [request] request))] 112 | (is (= {:params {:lol "bar", :foo 123} 113 | :headers {"content-type" "application/json"} 114 | :json-params {:foo 123}} 115 | (-> {:body (->stream "{\"foo\": 123}") 116 | :params {:lol "bar"} 117 | :headers {"content-type" "application/json"}} 118 | (handler) 119 | (dissoc :body))))) 120 | 121 | (let [handler 122 | (ring/wrap-json-params 123 | (fn [request] request))] 124 | (is (= {:status 400 125 | :headers {"content-type" "text/plain"} 126 | :body "Malformed JSON payload"} 127 | (handler {:body (->stream "dunno-lol") 128 | :headers {"content-type" "Application/Json"}}))))) 129 | 130 | 131 | (deftest test-wrap-ring-exception 132 | 133 | (let [handler 134 | (ring/wrap-ring-exception 135 | (fn [_request] 136 | (throw (ex-info "boom" {:aaa 1}))))] 137 | 138 | (is (= {:status 500 139 | :headers {"content-type" "text/plain"} 140 | :body "Internal server error"} 141 | (handler {:method :get}))))) 142 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | component 2 | integrant 3 | --------------------------------------------------------------------------------