├── .github ├── dependabot.yml └── workflows │ └── build-test-coverage.yml ├── .gitignore ├── .java-version ├── CODEOWNERS ├── LICENSE ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── jitpack.yml ├── mise.toml ├── router-openapi-request-validator ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── io │ │ └── moia │ │ └── router │ │ └── openapi │ │ ├── OpenApiValidator.kt │ │ └── ValidatingRequestRouterWrapper.kt │ └── test │ ├── kotlin │ └── io │ │ └── moia │ │ └── router │ │ └── openapi │ │ ├── OpenApiValidatorTest.kt │ │ └── ValidatingRequestRouterWrapperTest.kt │ └── resources │ └── openapi.yml ├── router-protobuf ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── io │ │ └── moia │ │ └── router │ │ └── proto │ │ ├── ProtoBufUtils.kt │ │ ├── ProtoDeserializationHandler.kt │ │ ├── ProtoEnabledRequestHandler.kt │ │ └── ProtoSerializationHandler.kt │ └── test │ ├── kotlin │ └── io │ │ └── moia │ │ └── router │ │ └── proto │ │ ├── ProtoBufUtilsTest.kt │ │ ├── ProtoDeserializationHandlerTest.kt │ │ └── RequestHandlerTest.kt │ └── proto │ └── Sample.proto ├── router ├── .build.gradle.kts.swp ├── build.gradle.kts └── src │ ├── main │ ├── kotlin │ │ └── io │ │ │ └── moia │ │ │ └── router │ │ │ ├── APIGatewayProxyEventExtensions.kt │ │ │ ├── ApiException.kt │ │ │ ├── DeserializationHandler.kt │ │ │ ├── MediaTypeExtensions.kt │ │ │ ├── PermissionHandler.kt │ │ │ ├── RequestHandler.kt │ │ │ ├── RequestPredicate.kt │ │ │ ├── ResponseEntity.kt │ │ │ ├── Router.kt │ │ │ ├── SerializationHandler.kt │ │ │ └── UriTemplate.kt │ └── resources │ │ └── log4j2.xml │ └── test │ └── kotlin │ └── io │ └── moia │ └── router │ ├── APIGatewayProxyEventExtensionsTest.kt │ ├── ApiRequestTest.kt │ ├── JsonDeserializationHandlerTest.kt │ ├── JwtPermissionHandlerTest.kt │ ├── MediaTypeTest.kt │ ├── NoOpPermissionHandlerTest.kt │ ├── PlainTextDeserializationHandlerTest.kt │ ├── PlainTextSerializationHandlerTest.kt │ ├── RequestHandlerTest.kt │ ├── ResponseEntityTest.kt │ ├── RouterTest.kt │ └── UriTemplateTest.kt ├── samples ├── lambda-kotlin-request-router-sample-proto │ ├── .DS_Store │ ├── build.gradle.kts │ ├── gradle │ │ └── wrapper │ │ │ ├── gradle-wrapper.jar │ │ │ └── gradle-wrapper.properties │ ├── gradlew │ ├── gradlew.bat │ ├── serverless.yml │ ├── settings.gradle │ └── src │ │ └── main │ │ ├── kotlin │ │ └── io │ │ │ └── moia │ │ │ └── router │ │ │ └── sample │ │ │ ├── MyRequestHandler.kt │ │ │ └── SomeController.kt │ │ └── proto │ │ └── Sample.proto └── lambda-kotlin-request-router-sample │ ├── build.gradle.kts │ ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties │ ├── gradlew │ ├── gradlew.bat │ ├── serverless.yml │ ├── settings.gradle │ └── src │ └── main │ └── kotlin │ └── io │ └── moia │ └── router │ └── sample │ ├── MyRequestHandler.kt │ └── SomeController.kt └── settings.gradle /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gradle" 4 | directory: "/" 5 | open-pull-requests-limit: 99 6 | schedule: 7 | interval: monthly 8 | groups: 9 | all-dependencies: 10 | patterns: 11 | - "*" 12 | - package-ecosystem: "gradle" 13 | directory: "/router/" 14 | open-pull-requests-limit: 99 15 | schedule: 16 | interval: monthly 17 | groups: 18 | all-dependencies: 19 | patterns: 20 | - "*" 21 | - package-ecosystem: "gradle" 22 | directory: "/router-protobuf/" 23 | open-pull-requests-limit: 99 24 | schedule: 25 | interval: monthly 26 | groups: 27 | all-dependencies: 28 | patterns: 29 | - "*" 30 | - package-ecosystem: "gradle" 31 | directory: "/router-openapi-request-validator/" 32 | open-pull-requests-limit: 99 33 | schedule: 34 | interval: monthly 35 | groups: 36 | all-dependencies: 37 | patterns: 38 | - "*" 39 | - package-ecosystem: "gradle" 40 | directory: "/samples/lambda-kotlin-request-router-sample/" 41 | open-pull-requests-limit: 99 42 | schedule: 43 | interval: monthly 44 | groups: 45 | all-dependencies: 46 | patterns: 47 | - "*" 48 | - package-ecosystem: "gradle" 49 | directory: "/samples/lambda-kotlin-request-router-sample-proto" 50 | open-pull-requests-limit: 99 51 | schedule: 52 | interval: monthly 53 | groups: 54 | all-dependencies: 55 | patterns: 56 | - "*" 57 | - package-ecosystem: "github-actions" 58 | directory: "/" 59 | open-pull-requests-limit: 99 60 | schedule: 61 | interval: monthly 62 | groups: 63 | all-dependencies: 64 | patterns: 65 | - "*" 66 | -------------------------------------------------------------------------------- /.github/workflows/build-test-coverage.yml: -------------------------------------------------------------------------------- 1 | 2 | name: build-test-coverage 3 | on: [push] 4 | jobs: 5 | build-test-coverage: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - name: Setup Java 17 10 | uses: actions/setup-java@v4 11 | with: 12 | java-version: 17 13 | distribution: adopt 14 | - uses: gradle/gradle-build-action@v3 15 | env: 16 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 17 | with: 18 | arguments: clean build jacocoRootReport coveralls -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | .gradle 3 | build/ 4 | bin/ 5 | .settings/ 6 | .project 7 | .classpath 8 | 9 | *.iml 10 | .idea/ 11 | out/ 12 | 13 | # Serverless directories 14 | .serverless 15 | 16 | -------------------------------------------------------------------------------- /.java-version: -------------------------------------------------------------------------------- 1 | 17.0 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://help.github.com/articles/about-codeowners/ 2 | # 3 | # These owners will be the default owners for everything in 4 | # the repo. Unless a later match takes precedence, 5 | # mentioned account names will be requested for 6 | # review when someone opens a pull request. 7 | * @mduesterhoeft @blockvote @jmoennich @raoulk @KennethWussmann @uberbinge @jwigankow 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://jitpack.io/v/io.moia/lambda-kotlin-request-router.svg)](https://jitpack.io/#io.moia/lambda-kotlin-request-router) 2 | [![Build Status](https://travis-ci.org/moia-oss/lambda-kotlin-request-router.svg?branch=master)](https://travis-ci.org/moia-oss/lambda-kotlin-request-router) 3 | [![Coverage Status](https://coveralls.io/repos/github/moia-oss/lambda-kotlin-request-router/badge.svg?branch=master)](https://coveralls.io/github/moia-oss/lambda-kotlin-request-router?branch=master) 4 | # lambda-kotlin-request-router 5 | 6 | A REST request routing layer for AWS lambda handlers written in Kotlin. 7 | 8 | ## Goal 9 | 10 | We came up `lambda-kotlin-request-router` to reduce boilerplate code when implementing a REST API handlers on AWS Lambda. 11 | 12 | The library addresses the following aspects: 13 | 14 | - serialization and deserialization 15 | - provide useful extensions and abstractions for API Gateway request and response types 16 | - writing REST handlers as functions 17 | - ease implementation of cross cutting concerns for handlers 18 | - ease (local) testing of REST handlers 19 | 20 | ## Reference 21 | 22 | ### Getting Started 23 | 24 | To use the core module we need the following: 25 | 26 | ```groovy 27 | repositories { 28 | maven { url 'https://jitpack.io' } 29 | } 30 | 31 | dependencies { 32 | implementation 'io.moia.lambda-kotlin-request-router:router:0.9.7' 33 | } 34 | 35 | ``` 36 | 37 | Having this we can now go ahead and implement our first handler. 38 | We can implement a request handler as a simple function. 39 | Request and response body are deserialized and serialized for you. 40 | 41 | ```kotlin 42 | import io.moia.router.Request 43 | import io.moia.router.RequestHandler 44 | import io.moia.router.ResponseEntity 45 | import io.moia.router.Router.Companion.router 46 | 47 | class MyRequestHandler : RequestHandler() { 48 | 49 | override val router = router { 50 | GET("/some") { r: Request -> ResponseEntity.ok(MyResponse(r.body)) } 51 | } 52 | } 53 | ``` 54 | 55 | ### Content Negotiation 56 | 57 | The router DSL allows for configuration of the content types a handler 58 | - produces (according to the request's `Accept` header) 59 | - consumes (according to the request's `Content-Type` header) 60 | 61 | The router itself carries a default for both values. 62 | 63 | ```kotlin 64 | var defaultConsuming = setOf("application/json") 65 | var defaultProducing = setOf("application/json") 66 | ``` 67 | 68 | These defaults can be overridden on the router level or on the handler level to specify the content types most of your handlers consume and produce. 69 | 70 | ```kotlin 71 | router { 72 | defaultConsuming = setOf("application/json") 73 | defaultProducing = setOf("application/json") 74 | } 75 | ``` 76 | 77 | Exceptions from this default can be configured on a handler level. 78 | 79 | ```kotlin 80 | router { 81 | POST("/some") { r: Request -> ResponseEntity.ok(MyResponse(r.body)) } 82 | .producing("application/json") 83 | .consuming("application/json") 84 | } 85 | ``` 86 | 87 | ### Filters 88 | 89 | Filters are a means to add cross-cutting concerns to your request handling logic outside a handler function. 90 | Multiple filters can be used by composing them. 91 | 92 | ```kotlin 93 | override val router = router { 94 | filter = loggingFilter().then(mdcFilter()) 95 | 96 | GET("/some", controller::get) 97 | } 98 | 99 | private fun loggingFilter() = Filter { next -> { 100 | request -> 101 | log.info("Handling request ${request.httpMethod} ${request.path}") 102 | next(request) } 103 | } 104 | 105 | private fun mdcFilter() = Filter { next -> { 106 | request -> 107 | MDC.put("requestId", request.requestContext?.requestId) 108 | next(request) } 109 | } 110 | } 111 | ``` 112 | 113 | ### Permissions 114 | 115 | Permission handling is a cross-cutting concern that can be handled outside the regular handler function. 116 | The routing DSL also supports expressing required permissions: 117 | 118 | ```kotlin 119 | override val router = router { 120 | GET("/some", controller::get).requiringPermissions("A_PERMISSION", "A_SECOND_PERMISSION") 121 | } 122 | ``` 123 | 124 | For the route above the `RequestHandler` checks if *any* of the listed permissions are found on a request. 125 | 126 | Additionally we need to configure a strategy to extract permissions from a request on the `RequestHandler`. 127 | By default a `RequestHandler` is using the `NoOpPermissionHandler` which always decides that any required permissions are found. 128 | The `JwtPermissionHandler` can be used to extract permissions from a JWT token found in a header. 129 | 130 | ```kotlin 131 | class TestRequestHandlerAuthorization : RequestHandler() { 132 | override val router = router { 133 | GET("/some", controller::get).requiringPermissions("A_PERMISSION") 134 | } 135 | 136 | override fun permissionHandlerSupplier(): (r: APIGatewayProxyRequestEvent) -> PermissionHandler = 137 | { JwtPermissionHandler( 138 | request = it, 139 | //the claim to use to extract the permissions - defaults to `scope` 140 | permissionsClaim = "permissions", 141 | //separator used to separate permissions in the claim - defaults to ` ` 142 | permissionSeparator = "," 143 | ) } 144 | } 145 | ``` 146 | 147 | Given the code above the token is extracted from the `Authorization` header. 148 | We can also choose to extract the token from a different header: 149 | 150 | ```kotlin 151 | JwtPermissionHandler( 152 | accessor = JwtAccessor( 153 | request = it, 154 | authorizationHeaderName = "custom-auth") 155 | ) 156 | ``` 157 | 158 | :warning: The implementation here assumes that JWT tokens are validated on the API Gateway. 159 | So we do no validation of the JWT token. 160 | 161 | ### Protobuf support 162 | 163 | The module `router-protobuf` helps to ease implementation of handlers that receive and return protobuf messages. 164 | 165 | ``` 166 | implementation 'io.moia.lambda-kotlin-request-router:router-protobuf:0.9.7' 167 | ``` 168 | 169 | A handler implementation that wants to take advantage of the protobuf support should inherit from `ProtoEnabledRequestHandler`. 170 | 171 | ```kotlin 172 | class TestRequestHandler : ProtoEnabledRequestHandler() { 173 | 174 | override val router = router { 175 | defaultProducing = setOf("application/x-protobuf") 176 | defaultConsuming = setOf("application/x-protobuf") 177 | 178 | defaultContentType = "application/x-protobuf" 179 | 180 | GET("/some-proto") { _: Request -> ResponseEntity.ok(Sample.newBuilder().setHello("Hello").build()) } 181 | .producing("application/x-protobuf", "application/json") 182 | POST("/some-proto") { r: Request -> ResponseEntity.ok(r.body) } 183 | GET("/some-error") { _: Request -> throw ApiException("boom", "BOOM", 400) } 184 | } 185 | 186 | override fun createErrorBody(error: ApiError): Any = 187 | io.moia.router.proto.sample.SampleOuterClass.ApiError.newBuilder() 188 | .setMessage(error.message) 189 | .setCode(error.code) 190 | .build() 191 | 192 | override fun createUnprocessableEntityErrorBody(errors: List): Any = 193 | errors.map { error -> 194 | io.moia.router.proto.sample.SampleOuterClass.UnprocessableEntityError.newBuilder() 195 | .setMessage(error.message) 196 | .setCode(error.code) 197 | .setPath(error.path) 198 | .build() 199 | } 200 | } 201 | ``` 202 | 203 | Make sure you override `createErrorBody` and `createUnprocessableEntityErrorBody` to map error type to your proto error messages. 204 | 205 | 206 | ### Open API validation support 207 | 208 | The module `router-openapi-request-validator` can be used to validate an interaction against an [OpenAPI](https://www.openapis.org/) specification. 209 | Internally we use the [swagger-request-validator](https://bitbucket.org/atlassian/swagger-request-validator) to achieve this. 210 | 211 | This library validates: 212 | - if the resource used is documented in the OpenApi specification 213 | - if request and response can be successfully validated against the request and response schema 214 | - ... 215 | 216 | ``` 217 | testImplementation 'io.moia.lambda-kotlin-request-router:router-openapi-request-validator:0.9.7' 218 | ``` 219 | 220 | ```kotlin 221 | val validator = OpenApiValidator("openapi.yml") 222 | 223 | @Test 224 | fun `should handle and validate request`() { 225 | val request = GET("/tests") 226 | .withHeaders(mapOf("Accept" to "application/json")) 227 | 228 | val response = testHandler.handleRequest(request, mockk()) 229 | 230 | validator.assertValidRequest(request) 231 | validator.assertValidResponse(request, response) 232 | validator.assertValid(request, response) 233 | } 234 | ``` 235 | 236 | If you want to validate all the API interactions in your handler tests against the API specification you can use `io.moia.router.openapi.ValidatingRequestRouterWrapper`. 237 | This a wrapper around your `RequestHandler` which transparently validates request and response. 238 | 239 | ```kotlin 240 | private val validatingRequestRouter = ValidatingRequestRouterWrapper(TestRequestHandler(), "openapi.yml") 241 | 242 | @Test 243 | fun `should return response on successful validation`() { 244 | val response = validatingRequestRouter 245 | .handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk()) 246 | 247 | then(response.statusCode).isEqualTo(200) 248 | } 249 | ``` 250 | 251 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 3 | import org.kt3k.gradle.plugin.CoverallsPluginExtension 4 | 5 | buildscript { 6 | repositories { 7 | mavenCentral() 8 | } 9 | } 10 | 11 | plugins { 12 | java 13 | kotlin("jvm") version "2.1.0" 14 | `maven-publish` 15 | jacoco 16 | id("com.github.kt3k.coveralls") version "2.12.2" 17 | id("org.jmailen.kotlinter") version "5.0.1" 18 | } 19 | 20 | group = "com.github.moia-dev" 21 | version = "1.0-SNAPSHOT" 22 | 23 | repositories { 24 | mavenCentral() 25 | } 26 | 27 | dependencies { 28 | implementation(kotlin("stdlib")) 29 | implementation(kotlin("reflect")) 30 | } 31 | 32 | subprojects { 33 | repositories { 34 | mavenCentral() 35 | } 36 | 37 | apply(plugin = "java") 38 | apply(plugin = "kotlin") 39 | apply(plugin = "jacoco") 40 | apply(plugin = "maven-publish") 41 | apply(plugin = "org.jmailen.kotlinter") 42 | 43 | tasks { 44 | withType { 45 | kotlinOptions.jvmTarget = "17" 46 | } 47 | 48 | withType { 49 | useJUnitPlatform() 50 | testLogging.showStandardStreams = true 51 | } 52 | } 53 | 54 | publishing { 55 | publications { 56 | create("maven") { 57 | from(components["java"]) 58 | pom { 59 | licenses { 60 | license { 61 | name.set("The Apache License, Version 2.0") 62 | url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | configure { 72 | sourceDirs = subprojects.flatMap { it.sourceSets["main"].allSource.srcDirs }.filter { it.exists() }.map { it.path } 73 | jacocoReportPath = "$buildDir/reports/jacoco/report.xml" 74 | } 75 | 76 | val jacocoRootReport by tasks.creating(JacocoReport::class) { 77 | description = "Generates an aggregate report from all subprojects" 78 | group = "Coverage reports" 79 | sourceSets(*subprojects.map { it.sourceSets["main"] }.toTypedArray()) 80 | executionData(fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec")) 81 | 82 | reports { 83 | xml.setDestination(File(project.buildDir, "reports/jacoco/report.xml")) 84 | } 85 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moia-oss/lambda-kotlin-request-router/d9793e80364cd4df43069e301f05d4c1fa04adb2/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /jitpack.yml: -------------------------------------------------------------------------------- 1 | jdk: 2 | - openjdk17 -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | java = "17" 3 | -------------------------------------------------------------------------------- /router-openapi-request-validator/build.gradle.kts: -------------------------------------------------------------------------------- 1 | repositories { 2 | mavenCentral() 3 | } 4 | 5 | dependencies { 6 | implementation(kotlin("stdlib")) 7 | implementation(kotlin("reflect")) 8 | 9 | api("com.atlassian.oai:swagger-request-validator-core:2.44.1") 10 | api(project(":router")) 11 | 12 | testImplementation("org.junit.jupiter:junit-jupiter-engine:5.11.4") 13 | testImplementation("org.assertj:assertj-core:3.27.2") 14 | testImplementation("io.mockk:mockk:1.13.14") 15 | testImplementation("org.slf4j:slf4j-simple:2.0.16") 16 | } -------------------------------------------------------------------------------- /router-openapi-request-validator/src/main/kotlin/io/moia/router/openapi/OpenApiValidator.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.openapi 2 | 3 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 4 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent 5 | import com.atlassian.oai.validator.OpenApiInteractionValidator 6 | import com.atlassian.oai.validator.model.Request 7 | import com.atlassian.oai.validator.model.Response 8 | import com.atlassian.oai.validator.model.SimpleRequest 9 | import com.atlassian.oai.validator.model.SimpleResponse 10 | import com.atlassian.oai.validator.report.ValidationReport 11 | import org.slf4j.LoggerFactory 12 | 13 | class OpenApiValidator( 14 | val specUrlOrPayload: String, 15 | ) { 16 | val validator = OpenApiInteractionValidator.createFor(specUrlOrPayload).build() 17 | 18 | fun validate( 19 | request: APIGatewayProxyRequestEvent, 20 | response: APIGatewayProxyResponseEvent, 21 | ): ValidationReport = 22 | validator 23 | .validate(request.toRequest(), response.toResponse()) 24 | .also { if (it.hasErrors()) log.error("error validating request and response against $specUrlOrPayload - $it") } 25 | 26 | fun assertValid( 27 | request: APIGatewayProxyRequestEvent, 28 | response: APIGatewayProxyResponseEvent, 29 | ) = validate(request, response).let { 30 | if (it.hasErrors()) { 31 | throw ApiInteractionInvalid( 32 | specUrlOrPayload, 33 | request, 34 | response, 35 | it, 36 | ) 37 | } 38 | } 39 | 40 | fun assertValidRequest(request: APIGatewayProxyRequestEvent) = 41 | validator.validateRequest(request.toRequest()).let { 42 | if (it.hasErrors()) { 43 | throw ApiInteractionInvalid( 44 | spec = specUrlOrPayload, 45 | request = request, 46 | validationReport = it, 47 | ) 48 | } 49 | } 50 | 51 | fun assertValidResponse( 52 | request: APIGatewayProxyRequestEvent, 53 | response: APIGatewayProxyResponseEvent, 54 | ) = request.toRequest().let { r -> 55 | validator.validateResponse(r.path, r.method, response.toResponse()).let { 56 | if (it.hasErrors()) { 57 | throw ApiInteractionInvalid( 58 | spec = specUrlOrPayload, 59 | request = request, 60 | validationReport = it, 61 | ) 62 | } 63 | } 64 | } 65 | 66 | class ApiInteractionInvalid( 67 | val spec: String, 68 | val request: APIGatewayProxyRequestEvent, 69 | val response: APIGatewayProxyResponseEvent? = null, 70 | val validationReport: ValidationReport, 71 | ) : RuntimeException("Error validating request and response against $spec - $validationReport") 72 | 73 | private fun APIGatewayProxyRequestEvent.toRequest(): Request { 74 | val builder = 75 | when (httpMethod.lowercase()) { 76 | "get" -> SimpleRequest.Builder.get(path) 77 | "post" -> SimpleRequest.Builder.post(path) 78 | "put" -> SimpleRequest.Builder.put(path) 79 | "patch" -> SimpleRequest.Builder.patch(path) 80 | "delete" -> SimpleRequest.Builder.delete(path) 81 | "options" -> SimpleRequest.Builder.options(path) 82 | "head" -> SimpleRequest.Builder.head(path) 83 | else -> throw IllegalArgumentException("Unsupported method $httpMethod") 84 | } 85 | headers?.forEach { builder.withHeader(it.key, it.value) } 86 | queryStringParameters?.forEach { builder.withQueryParam(it.key, it.value) } 87 | builder.withBody(body) 88 | return builder.build() 89 | } 90 | 91 | private fun APIGatewayProxyResponseEvent.toResponse(): Response = 92 | SimpleResponse.Builder 93 | .status(statusCode) 94 | .withBody(body) 95 | .also { headers.forEach { h -> it.withHeader(h.key, h.value) } } 96 | .build() 97 | 98 | companion object { 99 | val log = LoggerFactory.getLogger(OpenApiValidator::class.java) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /router-openapi-request-validator/src/main/kotlin/io/moia/router/openapi/ValidatingRequestRouterWrapper.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.openapi 2 | 3 | import com.amazonaws.services.lambda.runtime.Context 4 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 5 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent 6 | import io.moia.router.RequestHandler 7 | import org.slf4j.LoggerFactory 8 | 9 | /** 10 | * A wrapper around a [io.moia.router.RequestHandler] that transparently validates every request/response against the OpenAPI spec. 11 | * 12 | * This can be used in tests to make sure the actual requests and responses match the API specification. 13 | * 14 | * It uses [OpenApiValidator] to do the validation. 15 | * 16 | * @property delegate the actual [io.moia.router.RequestHandler] to forward requests to. 17 | * @property specFile the location of the OpenAPI / Swagger specification to use in the validator, or the inline specification to use. See also [com.atlassian.oai.validator.OpenApiInteractionValidator.createFor]] 18 | */ 19 | class ValidatingRequestRouterWrapper( 20 | val delegate: RequestHandler, 21 | specUrlOrPayload: String, 22 | private val additionalRequestValidationFunctions: List<(APIGatewayProxyRequestEvent) -> Unit> = emptyList(), 23 | private val additionalResponseValidationFunctions: List< 24 | ( 25 | APIGatewayProxyRequestEvent, 26 | APIGatewayProxyResponseEvent, 27 | ) -> Unit, 28 | > = emptyList(), 29 | ) { 30 | private val openApiValidator = OpenApiValidator(specUrlOrPayload) 31 | 32 | fun handleRequest( 33 | input: APIGatewayProxyRequestEvent, 34 | context: Context, 35 | ): APIGatewayProxyResponseEvent = 36 | handleRequest(input = input, context = context, skipRequestValidation = false, skipResponseValidation = false) 37 | 38 | fun handleRequestSkippingRequestAndResponseValidation( 39 | input: APIGatewayProxyRequestEvent, 40 | context: Context, 41 | ): APIGatewayProxyResponseEvent = 42 | handleRequest(input = input, context = context, skipRequestValidation = true, skipResponseValidation = true) 43 | 44 | private fun handleRequest( 45 | input: APIGatewayProxyRequestEvent, 46 | context: Context, 47 | skipRequestValidation: Boolean, 48 | skipResponseValidation: Boolean, 49 | ): APIGatewayProxyResponseEvent { 50 | if (!skipRequestValidation) { 51 | try { 52 | openApiValidator.assertValidRequest(input) 53 | runAdditionalRequestValidations(input) 54 | } catch (e: Exception) { 55 | log.error("Validation failed for request $input", e) 56 | throw e 57 | } 58 | } 59 | val response = delegate.handleRequest(input, context) 60 | if (!skipResponseValidation) { 61 | try { 62 | runAdditionalResponseValidations(input, response) 63 | openApiValidator.assertValidResponse(input, response) 64 | } catch (e: Exception) { 65 | log.error("Validation failed for response $response", e) 66 | throw e 67 | } 68 | } 69 | 70 | return response 71 | } 72 | 73 | private fun runAdditionalRequestValidations(requestEvent: APIGatewayProxyRequestEvent) { 74 | additionalRequestValidationFunctions.forEach { it(requestEvent) } 75 | } 76 | 77 | private fun runAdditionalResponseValidations( 78 | requestEvent: APIGatewayProxyRequestEvent, 79 | responseEvent: APIGatewayProxyResponseEvent, 80 | ) { 81 | additionalResponseValidationFunctions.forEach { it(requestEvent, responseEvent) } 82 | } 83 | 84 | companion object { 85 | private val log = LoggerFactory.getLogger(ValidatingRequestRouterWrapper::class.java) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /router-openapi-request-validator/src/test/kotlin/io/moia/router/openapi/OpenApiValidatorTest.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.openapi 2 | 3 | import io.mockk.mockk 4 | import io.moia.router.GET 5 | import io.moia.router.Request 6 | import io.moia.router.RequestHandler 7 | import io.moia.router.ResponseEntity 8 | import io.moia.router.Router 9 | import org.assertj.core.api.BDDAssertions.thenThrownBy 10 | import org.junit.jupiter.api.Test 11 | 12 | class OpenApiValidatorTest { 13 | val testHandler = TestRequestHandler() 14 | 15 | val validator = OpenApiValidator("openapi.yml") 16 | 17 | @Test 18 | fun `should handle and validate request`() { 19 | val request = 20 | GET("/tests") 21 | .withHeaders(mapOf("Accept" to "application/json")) 22 | 23 | val response = testHandler.handleRequest(request, mockk()) 24 | 25 | validator.assertValidRequest(request) 26 | validator.assertValidResponse(request, response) 27 | validator.assertValid(request, response) 28 | } 29 | 30 | @Test 31 | fun `should fail on undocumented request`() { 32 | val request = 33 | GET("/tests-not-documented") 34 | .withHeaders(mapOf("Accept" to "application/json")) 35 | 36 | val response = testHandler.handleRequest(request, mockk()) 37 | 38 | thenThrownBy { validator.assertValid(request, response) }.isInstanceOf(OpenApiValidator.ApiInteractionInvalid::class.java) 39 | thenThrownBy { validator.assertValidRequest(request) }.isInstanceOf(OpenApiValidator.ApiInteractionInvalid::class.java) 40 | } 41 | 42 | @Test 43 | fun `should fail on invalid schema`() { 44 | val request = 45 | GET("/tests") 46 | .withHeaders(mapOf("Accept" to "application/json")) 47 | 48 | val response = 49 | TestInvalidRequestHandler() 50 | .handleRequest(request, mockk()) 51 | 52 | thenThrownBy { validator.assertValid(request, response) }.isInstanceOf(OpenApiValidator.ApiInteractionInvalid::class.java) 53 | } 54 | 55 | class TestRequestHandler : RequestHandler() { 56 | data class TestResponse( 57 | val name: String, 58 | ) 59 | 60 | override val router = 61 | Router.router { 62 | GET("/tests") { _: Request -> 63 | ResponseEntity.ok(TestResponse("Hello")) 64 | } 65 | GET("/tests-not-documented") { _: Request -> 66 | ResponseEntity.ok(TestResponse("Hello")) 67 | } 68 | } 69 | } 70 | 71 | class TestInvalidRequestHandler : RequestHandler() { 72 | data class TestResponseInvalid( 73 | val invalid: String, 74 | ) 75 | 76 | override val router = 77 | Router.router { 78 | GET("/tests") { _: Request -> 79 | ResponseEntity.ok(TestResponseInvalid("Hello")) 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /router-openapi-request-validator/src/test/kotlin/io/moia/router/openapi/ValidatingRequestRouterWrapperTest.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.openapi 2 | 3 | import io.mockk.mockk 4 | import io.moia.router.GET 5 | import io.moia.router.Request 6 | import io.moia.router.RequestHandler 7 | import io.moia.router.ResponseEntity 8 | import io.moia.router.Router.Companion.router 9 | import io.moia.router.withAcceptHeader 10 | import org.assertj.core.api.BDDAssertions.then 11 | import org.assertj.core.api.BDDAssertions.thenThrownBy 12 | import org.junit.jupiter.api.Test 13 | 14 | class ValidatingRequestRouterWrapperTest { 15 | @Test 16 | fun `should return response on successful validation`() { 17 | val response = 18 | ValidatingRequestRouterWrapper(TestRequestHandler(), "openapi.yml") 19 | .handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk()) 20 | 21 | then(response.statusCode).isEqualTo(200) 22 | } 23 | 24 | @Test 25 | fun `should fail on response validation error`() { 26 | thenThrownBy { 27 | ValidatingRequestRouterWrapper(InvalidTestRequestHandler(), "openapi.yml") 28 | .handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk()) 29 | }.isInstanceOf(OpenApiValidator.ApiInteractionInvalid::class.java) 30 | .hasMessageContaining("Response status 404 not defined for path") 31 | } 32 | 33 | @Test 34 | fun `should fail on request validation error`() { 35 | thenThrownBy { 36 | ValidatingRequestRouterWrapper(InvalidTestRequestHandler(), "openapi.yml") 37 | .handleRequest(GET("/path-not-documented").withAcceptHeader("application/json"), mockk()) 38 | }.isInstanceOf(OpenApiValidator.ApiInteractionInvalid::class.java) 39 | .hasMessageContaining("No API path found that matches request") 40 | } 41 | 42 | @Test 43 | fun `should skip validation`() { 44 | val response = 45 | ValidatingRequestRouterWrapper(InvalidTestRequestHandler(), "openapi.yml") 46 | .handleRequestSkippingRequestAndResponseValidation( 47 | GET("/path-not-documented").withAcceptHeader("application/json"), 48 | mockk(), 49 | ) 50 | then(response.statusCode).isEqualTo(404) 51 | } 52 | 53 | @Test 54 | fun `should apply additional request validation`() { 55 | thenThrownBy { 56 | ValidatingRequestRouterWrapper( 57 | delegate = OpenApiValidatorTest.TestRequestHandler(), 58 | specUrlOrPayload = "openapi.yml", 59 | additionalRequestValidationFunctions = listOf({ _ -> throw RequestValidationFailedException() }), 60 | ).handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk()) 61 | }.isInstanceOf(RequestValidationFailedException::class.java) 62 | } 63 | 64 | @Test 65 | fun `should apply additional response validation`() { 66 | thenThrownBy { 67 | ValidatingRequestRouterWrapper( 68 | delegate = OpenApiValidatorTest.TestRequestHandler(), 69 | specUrlOrPayload = "openapi.yml", 70 | additionalResponseValidationFunctions = listOf({ _, _ -> throw ResponseValidationFailedException() }), 71 | ).handleRequest(GET("/tests").withAcceptHeader("application/json"), mockk()) 72 | }.isInstanceOf(ResponseValidationFailedException::class.java) 73 | } 74 | 75 | private class RequestValidationFailedException : RuntimeException("request validation failed") 76 | 77 | private class ResponseValidationFailedException : RuntimeException("request validation failed") 78 | 79 | private class TestRequestHandler : RequestHandler() { 80 | override val router = 81 | router { 82 | GET("/tests") { _: Request -> 83 | ResponseEntity.ok("""{"name": "some"}""") 84 | } 85 | } 86 | } 87 | 88 | private class InvalidTestRequestHandler : RequestHandler() { 89 | override val router = 90 | router { 91 | GET("/tests") { _: Request -> 92 | ResponseEntity.notFound(Unit) 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /router-openapi-request-validator/src/test/resources/openapi.yml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Test 5 | paths: 6 | /tests: 7 | get: 8 | summary: List all test records 9 | operationId: get-tests 10 | tags: 11 | - tests 12 | responses: 13 | '200': 14 | description: All tests 15 | content: 16 | application/json: 17 | schema: 18 | $ref: "#/components/schemas/Test" 19 | components: 20 | schemas: 21 | Test: 22 | required: 23 | - name 24 | properties: 25 | name: 26 | type: string 27 | description: unique id of the service area -------------------------------------------------------------------------------- /router-protobuf/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.google.protobuf") version "0.9.4" 3 | } 4 | 5 | repositories { 6 | mavenCentral() 7 | } 8 | 9 | val protoVersion = "4.29.3" 10 | 11 | dependencies { 12 | implementation(kotlin("stdlib")) 13 | implementation(kotlin("reflect")) 14 | 15 | implementation("org.slf4j:slf4j-api:2.0.16") 16 | api("com.google.protobuf:protobuf-java:$protoVersion") 17 | api("com.google.protobuf:protobuf-java-util:$protoVersion") 18 | implementation("com.google.guava:guava:33.4.0-jre") 19 | api(project(":router")) 20 | 21 | testImplementation("org.junit.jupiter:junit-jupiter-engine:5.11.4") 22 | testImplementation("com.willowtreeapps.assertk:assertk-jvm:0.28.1") 23 | testImplementation("org.assertj:assertj-core:3.27.2") 24 | testImplementation("io.mockk:mockk:1.13.14") 25 | testImplementation("org.slf4j:slf4j-simple:2.0.16") 26 | testImplementation("com.jayway.jsonpath:json-path:2.9.0") 27 | } 28 | 29 | protobuf { 30 | // Configure the protoc executable 31 | protoc { 32 | // Download from repositories 33 | artifact = "com.google.protobuf:protoc:$protoVersion" 34 | } 35 | } -------------------------------------------------------------------------------- /router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoBufUtils.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.proto 2 | 3 | import com.fasterxml.jackson.databind.JsonNode 4 | import com.fasterxml.jackson.databind.node.ArrayNode 5 | import com.fasterxml.jackson.databind.node.ObjectNode 6 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 7 | import com.google.protobuf.GeneratedMessage 8 | import com.google.protobuf.util.JsonFormat 9 | 10 | object ProtoBufUtils { 11 | fun toJsonWithoutWrappers(proto: GeneratedMessage): String { 12 | val message = 13 | JsonFormat 14 | .printer() 15 | .omittingInsignificantWhitespace() 16 | .alwaysPrintFieldsWithNoPresence() 17 | .print(proto) 18 | return removeWrapperObjects(message) 19 | } 20 | 21 | fun removeWrapperObjects(json: String): String = 22 | removeWrapperObjects( 23 | jacksonObjectMapper().readTree(json), 24 | ).toString() 25 | 26 | fun removeWrapperObjects(json: JsonNode): JsonNode { 27 | if (json.isArray) { 28 | return removeWrapperObjects(json as ArrayNode) 29 | } else if (json.isObject) { 30 | if (json.has("value") && json.size() == 1) { 31 | return json.get("value") 32 | } 33 | return removeWrapperObjects(json as ObjectNode) 34 | } 35 | return json 36 | } 37 | 38 | private fun removeWrapperObjects(json: ObjectNode): ObjectNode { 39 | val result = jacksonObjectMapper().createObjectNode() 40 | for (entry in json.fields()) { 41 | if (entry.value.isContainerNode && entry.value.size() > 0) { 42 | if (entry.value.size() > 0) { 43 | result.replace( 44 | entry.key, 45 | removeWrapperObjects(entry.value), 46 | ) 47 | } 48 | } else { 49 | result.replace(entry.key, entry.value) 50 | } 51 | } 52 | return result 53 | } 54 | 55 | private fun removeWrapperObjects(json: ArrayNode): ArrayNode { 56 | val result = jacksonObjectMapper().createArrayNode() 57 | for (entry in json) { 58 | result.add(removeWrapperObjects(entry)) 59 | } 60 | return result 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoDeserializationHandler.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.proto 2 | 3 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 4 | import com.google.common.net.MediaType 5 | import com.google.protobuf.Parser 6 | import io.moia.router.DeserializationHandler 7 | import io.moia.router.contentType 8 | import isCompatibleWith 9 | import java.util.Base64 10 | import kotlin.reflect.KClass 11 | import kotlin.reflect.KType 12 | import kotlin.reflect.full.staticFunctions 13 | 14 | class ProtoDeserializationHandler : DeserializationHandler { 15 | private val proto = MediaType.parse("application/x-protobuf") 16 | private val protoStructuredSuffixWildcard = MediaType.parse("application/*+x-protobuf") 17 | 18 | override fun supports(input: APIGatewayProxyRequestEvent): Boolean = 19 | if (input.contentType() == null) { 20 | false 21 | } else { 22 | MediaType.parse(input.contentType()!!).let { proto.isCompatibleWith(it) || protoStructuredSuffixWildcard.isCompatibleWith(it) } 23 | } 24 | 25 | override fun deserialize( 26 | input: APIGatewayProxyRequestEvent, 27 | target: KType?, 28 | ): Any { 29 | val bytes = Base64.getDecoder().decode(input.body) 30 | val parser = (target?.classifier as KClass<*>).staticFunctions.first { it.name == "parser" }.call() as Parser<*> 31 | return parser.parseFrom(bytes) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoEnabledRequestHandler.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.proto 2 | 3 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent 4 | import com.google.common.net.MediaType 5 | import io.moia.router.RequestHandler 6 | import io.moia.router.ResponseEntity 7 | 8 | abstract class ProtoEnabledRequestHandler : RequestHandler() { 9 | override fun serializationHandlers() = listOf(ProtoSerializationHandler()) + super.serializationHandlers() 10 | 11 | override fun deserializationHandlers() = listOf(ProtoDeserializationHandler()) + super.deserializationHandlers() 12 | 13 | override fun createResponse( 14 | contentType: MediaType, 15 | response: ResponseEntity, 16 | ): APIGatewayProxyResponseEvent = super.createResponse(contentType, response).withIsBase64Encoded(true) 17 | } 18 | -------------------------------------------------------------------------------- /router-protobuf/src/main/kotlin/io/moia/router/proto/ProtoSerializationHandler.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.proto 2 | 3 | import com.google.common.net.MediaType 4 | import com.google.protobuf.GeneratedMessage 5 | import io.moia.router.SerializationHandler 6 | import isCompatibleWith 7 | import java.util.Base64 8 | 9 | class ProtoSerializationHandler : SerializationHandler { 10 | private val json = MediaType.parse("application/json") 11 | private val jsonStructuredSuffixWildcard = MediaType.parse("application/*+json") 12 | 13 | override fun supports( 14 | acceptHeader: MediaType, 15 | body: Any, 16 | ): Boolean = body is GeneratedMessage 17 | 18 | override fun serialize( 19 | acceptHeader: MediaType, 20 | body: Any, 21 | ): String { 22 | val message = body as GeneratedMessage 23 | return if (json.isCompatibleWith(acceptHeader) || jsonStructuredSuffixWildcard.isCompatibleWith(acceptHeader)) { 24 | ProtoBufUtils.toJsonWithoutWrappers(message) 25 | } else { 26 | Base64.getEncoder().encodeToString(message.toByteArray()) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /router-protobuf/src/test/kotlin/io/moia/router/proto/ProtoBufUtilsTest.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.proto 2 | 3 | import com.google.protobuf.StringValue 4 | import com.jayway.jsonpath.JsonPath 5 | import io.moia.router.proto.sample.SampleOuterClass.ComplexSample 6 | import io.moia.router.proto.sample.SampleOuterClass.ComplexSample.SampleEnum.ONE 7 | import org.assertj.core.api.BDDAssertions.then 8 | import org.junit.jupiter.api.Test 9 | 10 | class ProtoBufUtilsTest { 11 | @Test 12 | fun `should serialize empty list`() { 13 | val message = 14 | ComplexSample 15 | .newBuilder() 16 | .addAllSamples(emptyList()) 17 | .build() 18 | 19 | val json = ProtoBufUtils.toJsonWithoutWrappers(message) 20 | 21 | then(JsonPath.read>(json, "samples")).isEmpty() 22 | } 23 | 24 | @Test 25 | fun `should remove wrapper object`() { 26 | val message = 27 | ComplexSample 28 | .newBuilder() 29 | .setSomeString(StringValue.newBuilder().setValue("some").build()) 30 | .build() 31 | 32 | val json = ProtoBufUtils.toJsonWithoutWrappers(message) 33 | 34 | then(JsonPath.read(json, "someString")).isEqualTo("some") 35 | } 36 | 37 | @Test 38 | fun `should serialize value when it is the default`() { 39 | val message = 40 | ComplexSample 41 | .newBuilder() 42 | .setEnumAttribute(ONE) // enum zero value 43 | .build() 44 | 45 | val json = ProtoBufUtils.toJsonWithoutWrappers(message) 46 | 47 | then(JsonPath.read(json, "enumAttribute")).isEqualTo("ONE") 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /router-protobuf/src/test/kotlin/io/moia/router/proto/ProtoDeserializationHandlerTest.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.proto 2 | 3 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 4 | import io.moia.router.withHeader 5 | import org.junit.jupiter.api.Assertions.assertFalse 6 | import org.junit.jupiter.api.Assertions.assertTrue 7 | import org.junit.jupiter.api.Test 8 | 9 | internal class ProtoDeserializationHandlerTest { 10 | @Test 11 | fun `Deserializer should not support if the content type of the input is null`() { 12 | assertFalse(ProtoDeserializationHandler().supports(APIGatewayProxyRequestEvent())) 13 | } 14 | 15 | @Test 16 | fun `Deserializer should not support if the content type of the input is json`() { 17 | assertFalse(ProtoDeserializationHandler().supports(APIGatewayProxyRequestEvent().withHeader("content-type", "application/json"))) 18 | } 19 | 20 | @Test 21 | fun `Deserializer should support if the content type of the input is protobuf`() { 22 | assertTrue( 23 | ProtoDeserializationHandler().supports(APIGatewayProxyRequestEvent().withHeader("content-type", "application/x-protobuf")), 24 | ) 25 | assertTrue( 26 | ProtoDeserializationHandler().supports( 27 | APIGatewayProxyRequestEvent().withHeader("content-type", "application/vnd.moia.v1+x-protobuf"), 28 | ), 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /router-protobuf/src/test/kotlin/io/moia/router/proto/RequestHandlerTest.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.proto 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import assertk.assertions.isTrue 6 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 7 | import io.mockk.mockk 8 | import io.moia.router.ApiError 9 | import io.moia.router.ApiException 10 | import io.moia.router.GET 11 | import io.moia.router.Request 12 | import io.moia.router.ResponseEntity 13 | import io.moia.router.Router.Companion.router 14 | import io.moia.router.UnprocessableEntityError 15 | import io.moia.router.bodyAsBytes 16 | import io.moia.router.proto.sample.SampleOuterClass.Sample 17 | import org.junit.jupiter.api.Test 18 | import java.util.Base64 19 | 20 | class RequestHandlerTest { 21 | private val testRequestHandler = TestRequestHandler() 22 | 23 | @Test 24 | fun `should match request to proto handler and return json`() { 25 | val response = 26 | testRequestHandler.handleRequest( 27 | APIGatewayProxyRequestEvent() 28 | .withPath("/some-proto") 29 | .withHttpMethod("GET") 30 | .withHeaders(mapOf("Accept" to "application/json")), 31 | mockk(), 32 | ) 33 | 34 | assertThat(response.statusCode).isEqualTo(200) 35 | assertThat(response.body).isEqualTo("""{"hello":"Hello","request":""}""") 36 | } 37 | 38 | @Test 39 | fun `should match request to proto handler with version accept header and return json`() { 40 | val response = 41 | testRequestHandler.handleRequest( 42 | APIGatewayProxyRequestEvent() 43 | .withPath("/some-proto") 44 | .withHttpMethod("GET") 45 | .withHeaders(mapOf("Accept" to "application/vnd.moia.v1+json")), 46 | mockk(), 47 | ) 48 | 49 | assertThat(response.statusCode).isEqualTo(200) 50 | assertThat(response.body).isEqualTo("""{"hello":"v1","request":""}""") 51 | } 52 | 53 | @Test 54 | fun `should match request to proto handler and return proto`() { 55 | val response = 56 | testRequestHandler.handleRequest( 57 | APIGatewayProxyRequestEvent() 58 | .withPath("/some-proto") 59 | .withHttpMethod("GET") 60 | .withHeaders(mapOf("Accept" to "application/x-protobuf")), 61 | mockk(), 62 | ) 63 | 64 | assertThat(response.statusCode).isEqualTo(200) 65 | assertThat(Sample.parseFrom(response.bodyAsBytes())).isEqualTo( 66 | Sample 67 | .newBuilder() 68 | .setHello("Hello") 69 | .setRequest("") 70 | .build(), 71 | ) 72 | } 73 | 74 | @Test 75 | fun `should match request to proto handler and deserialize and return proto`() { 76 | val request = 77 | Sample 78 | .newBuilder() 79 | .setHello("Hello") 80 | .setRequest("") 81 | .build() 82 | 83 | val response = 84 | testRequestHandler.handleRequest( 85 | APIGatewayProxyRequestEvent() 86 | .withPath("/some-proto") 87 | .withHttpMethod("POST") 88 | .withBody(Base64.getEncoder().encodeToString(request.toByteArray())) 89 | .withHeaders( 90 | mapOf( 91 | "Accept" to "application/x-protobuf", 92 | "Content-Type" to "application/x-protobuf", 93 | ), 94 | ), 95 | mockk(), 96 | ) 97 | 98 | assertThat(response.statusCode).isEqualTo(200) 99 | assertThat(Sample.parseFrom(response.bodyAsBytes())).isEqualTo(request) 100 | assertThat(response.isBase64Encoded).isTrue() 101 | } 102 | 103 | @Test 104 | fun `should return 406-unacceptable error in proto`() { 105 | val response = 106 | testRequestHandler.handleRequest( 107 | GET("/some-proto") 108 | .withHeaders( 109 | mapOf( 110 | "Accept" to "text/plain", 111 | ), 112 | ), 113 | mockk(), 114 | ) 115 | 116 | assertThat(response.statusCode).isEqualTo(406) 117 | assertThat( 118 | io.moia.router.proto.sample.SampleOuterClass.ApiError 119 | .parseFrom(response.bodyAsBytes()) 120 | .getCode(), 121 | ).isEqualTo("NOT_ACCEPTABLE") 122 | } 123 | 124 | @Test 125 | fun `should return api error in protos`() { 126 | val response = 127 | testRequestHandler.handleRequest( 128 | GET("/some-error") 129 | .withHeaders( 130 | mapOf( 131 | "Accept" to "application/x-protobuf", 132 | ), 133 | ), 134 | mockk(), 135 | ) 136 | 137 | assertThat(response.statusCode).isEqualTo(400) 138 | with( 139 | io.moia.router.proto.sample.SampleOuterClass.ApiError 140 | .parseFrom(response.bodyAsBytes()), 141 | ) { 142 | assertThat(getCode()).isEqualTo("BOOM") 143 | assertThat(getMessage()).isEqualTo("boom") 144 | } 145 | } 146 | 147 | class TestRequestHandler : ProtoEnabledRequestHandler() { 148 | override val router = 149 | router { 150 | defaultProducing = setOf("application/x-protobuf") 151 | defaultConsuming = setOf("application/x-protobuf") 152 | 153 | defaultContentType = "application/x-protobuf" 154 | 155 | GET("/some-proto") { _: Request -> ResponseEntity.ok(Sample.newBuilder().setHello("v1").build()) } 156 | .producing("application/vnd.moia.v1+x-protobuf", "application/vnd.moia.v1+json") 157 | 158 | GET("/some-proto") { _: Request -> ResponseEntity.ok(Sample.newBuilder().setHello("Hello").build()) } 159 | .producing("application/x-protobuf", "application/json") 160 | POST("/some-proto") { r: Request -> ResponseEntity.ok(r.body) } 161 | GET("/some-error") { _: Request -> throw ApiException("boom", "BOOM", 400) } 162 | } 163 | 164 | override fun createErrorBody(error: ApiError): Any = 165 | io.moia.router.proto.sample.SampleOuterClass.ApiError 166 | .newBuilder() 167 | .setMessage(error.message) 168 | .setCode(error.code) 169 | .build() 170 | 171 | override fun createUnprocessableEntityErrorBody(errors: List): Any = 172 | errors.map { error -> 173 | io.moia.router.proto.sample.SampleOuterClass.UnprocessableEntityError 174 | .newBuilder() 175 | .setMessage(error.message) 176 | .setCode(error.code) 177 | .setPath(error.path) 178 | .build() 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /router-protobuf/src/test/proto/Sample.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package io.moia.router.proto.sample; 4 | 5 | option java_multiple_files = false; 6 | option java_outer_classname = "SampleOuterClass"; 7 | option java_package = "io.moia.router.proto.sample"; 8 | 9 | import "google/protobuf/wrappers.proto"; 10 | 11 | message Sample { 12 | string hello = 1; 13 | string request = 2; 14 | } 15 | 16 | message ApiError { 17 | string message = 1; 18 | string code = 2; 19 | } 20 | 21 | message UnprocessableEntityError { 22 | string message = 1; 23 | string code = 2; 24 | string path = 3; 25 | } 26 | 27 | message ComplexSample { 28 | enum SampleEnum { 29 | ONE = 0; 30 | TWO = 1; 31 | } 32 | 33 | SampleEnum enumAttribute = 1; 34 | repeated Sample samples = 2; 35 | google.protobuf.StringValue someString = 3; 36 | } 37 | 38 | -------------------------------------------------------------------------------- /router/.build.gradle.kts.swp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moia-oss/lambda-kotlin-request-router/d9793e80364cd4df43069e301f05d4c1fa04adb2/router/.build.gradle.kts.swp -------------------------------------------------------------------------------- /router/build.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | 3 | dependencies { 4 | implementation(kotlin("stdlib")) 5 | implementation(kotlin("reflect")) 6 | api("com.amazonaws:aws-lambda-java-core:1.2.3") 7 | api("com.amazonaws:aws-lambda-java-events:3.14.0") 8 | 9 | implementation("org.slf4j:slf4j-api:2.0.16") 10 | api("com.fasterxml.jackson.core:jackson-databind:2.18.2") 11 | api("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2") 12 | api("com.google.guava:guava:33.4.0-jre") 13 | 14 | testImplementation("org.junit.jupiter:junit-jupiter-engine:5.11.4") 15 | testImplementation("org.junit.jupiter:junit-jupiter-params:5.11.4") 16 | testImplementation("com.willowtreeapps.assertk:assertk-jvm:0.28.1") 17 | testImplementation("org.assertj:assertj-core:3.27.2") 18 | testImplementation("io.mockk:mockk:1.13.14") 19 | testImplementation("ch.qos.logback:logback-classic:1.5.16") 20 | } 21 | -------------------------------------------------------------------------------- /router/src/main/kotlin/io/moia/router/APIGatewayProxyEventExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 20 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent 21 | import com.google.common.net.MediaType 22 | import java.net.URI 23 | import java.util.Base64 24 | 25 | /** Data class that represents an HTTP header */ 26 | data class Header( 27 | val name: String, 28 | val value: String, 29 | ) 30 | 31 | fun APIGatewayProxyRequestEvent.acceptHeader() = getHeaderCaseInsensitive("accept") 32 | 33 | fun APIGatewayProxyRequestEvent.acceptedMediaTypes() = 34 | acceptHeader() 35 | ?.split(",") 36 | ?.map { it.trim() } 37 | ?.mapNotNull { parseMediaTypeSafe(it) } 38 | .orEmpty() 39 | 40 | fun APIGatewayProxyRequestEvent.contentType() = getHeaderCaseInsensitive("content-type") 41 | 42 | fun APIGatewayProxyRequestEvent.getHeaderCaseInsensitive(httpHeader: String): String? = getCaseInsensitive(httpHeader, headers) 43 | 44 | fun APIGatewayProxyResponseEvent.getHeaderCaseInsensitive(httpHeader: String): String? = getCaseInsensitive(httpHeader, headers) 45 | 46 | @Suppress("FunctionName") 47 | fun GET() = APIGatewayProxyRequestEvent().withHttpMethod("get").withHeaders(mutableMapOf()) 48 | 49 | @Suppress("FunctionName") 50 | fun GET(path: String) = GET().withPath(path) 51 | 52 | @Suppress("FunctionName") 53 | fun POST() = APIGatewayProxyRequestEvent().withHttpMethod("post").withHeaders(mutableMapOf()) 54 | 55 | @Suppress("FunctionName") 56 | fun POST(path: String) = POST().withPath(path) 57 | 58 | @Suppress("FunctionName") 59 | fun PUT() = APIGatewayProxyRequestEvent().withHttpMethod("put").withHeaders(mutableMapOf()) 60 | 61 | @Suppress("FunctionName") 62 | fun PUT(path: String) = PUT().withPath(path) 63 | 64 | @Suppress("FunctionName") 65 | fun PATCH() = APIGatewayProxyRequestEvent().withHttpMethod("patch").withHeaders(mutableMapOf()) 66 | 67 | @Suppress("FunctionName") 68 | fun PATCH(path: String) = PATCH().withPath(path) 69 | 70 | @Suppress("FunctionName") 71 | fun DELETE() = APIGatewayProxyRequestEvent().withHttpMethod("delete").withHeaders(mutableMapOf()) 72 | 73 | @Suppress("FunctionName") 74 | fun DELETE(path: String) = DELETE().withPath(path) 75 | 76 | /** 77 | * Get a URI that can be used as location header for responses. 78 | * The host is taken from the Host header. 79 | * The protocol is taken from the x-forwarded-proto. 80 | * The port is taken from the x-forwarded-port header. Standard ports are omitted. 81 | */ 82 | fun APIGatewayProxyRequestEvent.location(path: String): URI { 83 | val host = getHeaderCaseInsensitive("host") ?: "localhost" 84 | val proto = getHeaderCaseInsensitive("x-forwarded-proto") ?: "http" 85 | val portPart = 86 | getHeaderCaseInsensitive("x-forwarded-port") 87 | ?.let { 88 | when { 89 | proto == "https" && it == "443" -> null 90 | proto == "http" && it == "80" -> null 91 | else -> ":$it" 92 | } 93 | } ?: "" 94 | return URI("$proto://$host$portPart/${path.removePrefix("/")}") 95 | } 96 | 97 | fun APIGatewayProxyRequestEvent.withHeader( 98 | name: String, 99 | value: String, 100 | ) = this.also { if (headers == null) headers = mutableMapOf() }.also { headers[name] = value } 101 | 102 | fun APIGatewayProxyRequestEvent.withHeader(header: Header) = this.withHeader(header.name, header.value) 103 | 104 | fun APIGatewayProxyRequestEvent.withAcceptHeader(accept: String) = this.withHeader("accept", accept) 105 | 106 | fun APIGatewayProxyRequestEvent.withContentTypeHeader(contentType: String) = this.withHeader("content-type", contentType) 107 | 108 | fun APIGatewayProxyResponseEvent.withHeader( 109 | name: String, 110 | value: String, 111 | ) = this.also { if (headers == null) headers = mutableMapOf() }.also { headers[name] = value } 112 | 113 | fun APIGatewayProxyResponseEvent.withHeader(header: Header) = this.withHeader(header.name, header.value) 114 | 115 | fun APIGatewayProxyResponseEvent.withLocationHeader( 116 | request: APIGatewayProxyRequestEvent, 117 | path: String, 118 | ) = this.also { if (headers == null) headers = mutableMapOf() }.also { headers["location"] = request.location(path).toString() } 119 | 120 | fun APIGatewayProxyResponseEvent.location() = getHeaderCaseInsensitive("location") 121 | 122 | private fun getCaseInsensitive( 123 | key: String, 124 | map: Map?, 125 | ): String? = 126 | map 127 | ?.entries 128 | ?.firstOrNull { key.equals(it.key, ignoreCase = true) } 129 | ?.value 130 | 131 | fun APIGatewayProxyResponseEvent.bodyAsBytes() = Base64.getDecoder().decode(body) 132 | 133 | private fun parseMediaTypeSafe(input: String): MediaType? = 134 | try { 135 | MediaType.parse(input) 136 | } catch (e: IllegalArgumentException) { 137 | null 138 | } 139 | -------------------------------------------------------------------------------- /router/src/main/kotlin/io/moia/router/ApiException.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | open class ApiException( 20 | message: String, 21 | val code: String, 22 | val httpResponseStatus: Int, 23 | val details: Map = emptyMap(), 24 | cause: Throwable? = null, 25 | ) : RuntimeException(message, cause) { 26 | override fun toString(): String = 27 | "ApiException(message='$message', code='$code', httpResponseStatus=$httpResponseStatus, details=$details, cause=$cause)" 28 | 29 | fun toApiError() = ApiError(super.message!!, code, details) 30 | 31 | inline fun toResponseEntity(mapper: (error: ApiError) -> Any = {}) = ResponseEntity(httpResponseStatus, mapper(toApiError())) 32 | } 33 | 34 | data class ApiError( 35 | val message: String, 36 | val code: String, 37 | val details: Map = emptyMap(), 38 | ) 39 | 40 | data class UnprocessableEntityError( 41 | val message: String, 42 | val code: String, 43 | val path: String, 44 | val details: Map = emptyMap(), 45 | ) 46 | -------------------------------------------------------------------------------- /router/src/main/kotlin/io/moia/router/DeserializationHandler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 20 | import com.fasterxml.jackson.databind.ObjectMapper 21 | import com.fasterxml.jackson.databind.type.TypeFactory 22 | import com.google.common.net.MediaType 23 | import isCompatibleWith 24 | import kotlin.reflect.KClass 25 | import kotlin.reflect.KType 26 | import kotlin.reflect.full.isSubclassOf 27 | 28 | interface DeserializationHandler { 29 | fun supports(input: APIGatewayProxyRequestEvent): Boolean 30 | 31 | fun deserialize( 32 | input: APIGatewayProxyRequestEvent, 33 | target: KType?, 34 | ): Any? 35 | } 36 | 37 | class DeserializationHandlerChain( 38 | private val handlers: List, 39 | ) : DeserializationHandler { 40 | override fun supports(input: APIGatewayProxyRequestEvent): Boolean = handlers.any { it.supports(input) } 41 | 42 | override fun deserialize( 43 | input: APIGatewayProxyRequestEvent, 44 | target: KType?, 45 | ): Any? = handlers.firstOrNull { it.supports(input) }?.deserialize(input, target) 46 | } 47 | 48 | class JsonDeserializationHandler( 49 | private val objectMapper: ObjectMapper, 50 | ) : DeserializationHandler { 51 | private val json = MediaType.parse("application/json; charset=UTF-8") 52 | private val jsonStructuredSuffixWildcard = MediaType.parse("application/*+json; charset=UTF-8") 53 | 54 | override fun supports(input: APIGatewayProxyRequestEvent) = 55 | if (input.contentType() == null) { 56 | false 57 | } else { 58 | MediaType 59 | .parse(input.contentType()!!) 60 | .let { json.isCompatibleWith(it) || jsonStructuredSuffixWildcard.isCompatibleWith(it) } 61 | } 62 | 63 | override fun deserialize( 64 | input: APIGatewayProxyRequestEvent, 65 | target: KType?, 66 | ): Any? { 67 | val targetClass = target?.classifier as KClass<*> 68 | return when { 69 | targetClass == Unit::class -> Unit 70 | targetClass == String::class -> input.body!! 71 | targetClass.isSubclassOf(Collection::class) -> { 72 | val kClass = 73 | target.arguments 74 | .first() 75 | .type!! 76 | .classifier as KClass<*> 77 | val type = 78 | TypeFactory 79 | .defaultInstance() 80 | .constructParametricType(targetClass.javaObjectType, kClass.javaObjectType) 81 | objectMapper.readValue(input.body, type) 82 | } 83 | else -> objectMapper.readValue(input.body, targetClass.java) 84 | } 85 | } 86 | } 87 | 88 | object PlainTextDeserializationHandler : DeserializationHandler { 89 | private val text = MediaType.parse("text/*") 90 | 91 | override fun supports(input: APIGatewayProxyRequestEvent): Boolean = 92 | if (input.contentType() == null) { 93 | false 94 | } else { 95 | MediaType.parse(input.contentType()!!).isCompatibleWith(text) 96 | } 97 | 98 | override fun deserialize( 99 | input: APIGatewayProxyRequestEvent, 100 | target: KType?, 101 | ): Any? = input.body 102 | } 103 | -------------------------------------------------------------------------------- /router/src/main/kotlin/io/moia/router/MediaTypeExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | import com.google.common.net.MediaType 18 | 19 | fun MediaType.isCompatibleWith(other: MediaType): Boolean = 20 | if (this.`is`(other)) { 21 | true 22 | } else { 23 | type() == other.type() && 24 | (subtype().contains("+") && other.subtype().contains("+")) && 25 | this 26 | .subtype() 27 | .substringBeforeLast("+") == "*" && 28 | this.subtype().substringAfterLast("+") == 29 | other 30 | .subtype() 31 | .substringAfterLast("+") && 32 | (other.parameters().isEmpty || this.parameters() == other.parameters()) 33 | } 34 | -------------------------------------------------------------------------------- /router/src/main/kotlin/io/moia/router/PermissionHandler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 20 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 21 | import com.fasterxml.jackson.module.kotlin.readValue 22 | import java.util.Base64 23 | 24 | interface PermissionHandler { 25 | fun hasAnyRequiredPermission(requiredPermissions: Set): Boolean 26 | } 27 | 28 | interface PredicatePermissionHandler { 29 | fun hasAnyRequiredPermission(predicate: RequestPredicate): Boolean 30 | } 31 | 32 | class NoOpPermissionHandler : PermissionHandler { 33 | override fun hasAnyRequiredPermission(requiredPermissions: Set) = true 34 | } 35 | 36 | open class JwtAccessor( 37 | private val request: APIGatewayProxyRequestEvent, 38 | private val authorizationHeaderName: String = "authorization", 39 | ) { 40 | private val objectMapper = jacksonObjectMapper() 41 | 42 | fun extractJwtToken(): String? = 43 | // support "Bearer " as well as "" 44 | request 45 | .getHeaderCaseInsensitive(authorizationHeaderName) 46 | ?.split(" ") 47 | ?.toList() 48 | ?.last() 49 | 50 | fun extractJwtClaims() = 51 | extractJwtToken() 52 | ?.let { token -> token.split("\\.".toRegex()).dropLastWhile { it.isEmpty() } } 53 | ?.takeIf { it.size == 3 } 54 | ?.let { it[1] } 55 | ?.let { jwtPayload -> 56 | try { 57 | String(Base64.getDecoder().decode(jwtPayload)) 58 | } catch (e: Exception) { 59 | return null 60 | } 61 | }?.let { objectMapper.readValue>(it) } 62 | } 63 | 64 | open class JwtPermissionHandler( 65 | val accessor: JwtAccessor, 66 | val permissionsClaim: String = DEFAULT_PERMISSIONS_CLAIM, 67 | val permissionSeparator: String = DEFAULT_PERMISSION_SEPARATOR, 68 | ) : PermissionHandler { 69 | constructor( 70 | request: APIGatewayProxyRequestEvent, 71 | permissionsClaim: String = DEFAULT_PERMISSIONS_CLAIM, 72 | permissionSeparator: String = DEFAULT_PERMISSION_SEPARATOR, 73 | ) : this(JwtAccessor(request), permissionsClaim, permissionSeparator) 74 | 75 | override fun hasAnyRequiredPermission(requiredPermissions: Set): Boolean = 76 | if (requiredPermissions.isEmpty()) { 77 | true 78 | } else { 79 | extractPermissions().any { requiredPermissions.contains(it) } 80 | } 81 | 82 | internal open fun extractPermissions(): Set = 83 | accessor 84 | .extractJwtClaims() 85 | ?.let { it[permissionsClaim] } 86 | ?.let { 87 | when (it) { 88 | is List<*> -> it.filterIsInstance(String::class.java).toSet() 89 | is String -> it.split(permissionSeparator).map { s -> s.trim() }.toSet() 90 | else -> null 91 | } 92 | } 93 | ?: emptySet() 94 | 95 | companion object { 96 | private const val DEFAULT_PERMISSIONS_CLAIM = "scope" 97 | private const val DEFAULT_PERMISSION_SEPARATOR: String = " " 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /router/src/main/kotlin/io/moia/router/RequestHandler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import com.amazonaws.services.lambda.runtime.Context 20 | import com.amazonaws.services.lambda.runtime.RequestHandler 21 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 22 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent 23 | import com.fasterxml.jackson.core.JsonParseException 24 | import com.fasterxml.jackson.databind.exc.InvalidDefinitionException 25 | import com.fasterxml.jackson.databind.exc.InvalidFormatException 26 | import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException 27 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 28 | import com.google.common.net.MediaType 29 | import org.slf4j.Logger 30 | import org.slf4j.LoggerFactory 31 | import kotlin.reflect.KClass 32 | 33 | abstract class RequestHandler : RequestHandler { 34 | open val objectMapper = jacksonObjectMapper() 35 | 36 | abstract val router: Router 37 | 38 | private val serializationHandlerChain by lazy { SerializationHandlerChain(serializationHandlers()) } 39 | private val deserializationHandlerChain by lazy { DeserializationHandlerChain(deserializationHandlers()) } 40 | 41 | override fun handleRequest( 42 | input: APIGatewayProxyRequestEvent, 43 | context: Context, 44 | ): APIGatewayProxyResponseEvent = 45 | input 46 | .apply { headers = headers.mapKeys { it.key.lowercase() } } 47 | .let { router.filter.then(this::handleRequest)(it) } 48 | 49 | @Suppress("UNCHECKED_CAST") 50 | private fun handleRequest(input: APIGatewayProxyRequestEvent): APIGatewayProxyResponseEvent { 51 | log.debug( 52 | "handling request with method '${input.httpMethod}' and path '${input.path}' - " + 53 | "Accept:${input.acceptHeader()} Content-Type:${input.contentType()} $input", 54 | ) 55 | val routes = router.routes as List> 56 | val matchResults: List = 57 | routes.map { routerFunction: RouterFunction -> 58 | val matchResult = routerFunction.requestPredicate.match(input) 59 | log.debug("match result for route '$routerFunction' is '$matchResult'") 60 | if (matchResult.match) { 61 | val matchedAcceptType = 62 | routerFunction.requestPredicate.matchedAcceptType(input.acceptedMediaTypes()) 63 | ?: MediaType.parse(router.defaultContentType) 64 | 65 | val handler: HandlerFunctionWrapper = routerFunction.handler 66 | 67 | val response = 68 | try { 69 | if (missingPermissions(input, routerFunction)) { 70 | throw ApiException("missing permissions", "MISSING_PERMISSIONS", 403) 71 | } else { 72 | val requestBody = deserializeRequest(handler, input) 73 | val request = Request(input, requestBody, routerFunction.requestPredicate.pathPattern) as Request 74 | handler.handlerFunction(request) 75 | } 76 | } catch (e: Exception) { 77 | exceptionToResponseEntity(e, input) 78 | } 79 | return createResponse(matchedAcceptType, response) 80 | } 81 | matchResult 82 | } 83 | return handleNonDirectMatch(MediaType.parse(router.defaultContentType), matchResults, input) 84 | } 85 | 86 | private fun exceptionToResponseEntity( 87 | e: Exception, 88 | input: APIGatewayProxyRequestEvent, 89 | ) = when (e) { 90 | is ApiException -> 91 | e 92 | .toResponseEntity(this::createErrorBody) 93 | .also { logApiException(e, input) } 94 | else -> 95 | exceptionToResponseEntity(e) 96 | .also { logUnknownException(e, input) } 97 | } 98 | 99 | private fun missingPermissions( 100 | input: APIGatewayProxyRequestEvent, 101 | routerFunction: RouterFunction, 102 | ): Boolean { 103 | if (predicatePermissionHandlerSupplier() != null) { 104 | return !predicatePermissionHandlerSupplier()!!(input).hasAnyRequiredPermission(routerFunction.requestPredicate) 105 | } 106 | return !permissionHandlerSupplier()(input).hasAnyRequiredPermission(routerFunction.requestPredicate.requiredPermissions) 107 | } 108 | 109 | /** 110 | * Hook to be able to override the way ApiExceptions are logged. 111 | */ 112 | open fun logApiException( 113 | e: ApiException, 114 | input: APIGatewayProxyRequestEvent, 115 | ) { 116 | log.info("Caught api error while handling ${input.httpMethod} ${input.path} - $e") 117 | } 118 | 119 | /** 120 | * Hook to be able to override the way non-ApiExceptions are logged. 121 | */ 122 | open fun logUnknownException( 123 | e: Exception, 124 | input: APIGatewayProxyRequestEvent, 125 | ) { 126 | log.error("Caught exception handling ${input.httpMethod} ${input.path} - $e", e) 127 | } 128 | 129 | open fun serializationHandlers(): List = 130 | listOf( 131 | JsonSerializationHandler(objectMapper), 132 | PlainTextSerializationHandler(), 133 | ) 134 | 135 | open fun deserializationHandlers(): List = 136 | listOf( 137 | JsonDeserializationHandler(objectMapper), 138 | ) 139 | 140 | open fun permissionHandlerSupplier(): (r: APIGatewayProxyRequestEvent) -> PermissionHandler = { NoOpPermissionHandler() } 141 | 142 | open fun predicatePermissionHandlerSupplier(): ((r: APIGatewayProxyRequestEvent) -> PredicatePermissionHandler)? = null 143 | 144 | private fun deserializeRequest( 145 | handler: HandlerFunctionWrapper, 146 | input: APIGatewayProxyRequestEvent, 147 | ): Any? = 148 | when { 149 | handler.requestType.classifier as KClass<*> == Unit::class -> Unit 150 | input.body == null && handler.requestType.isMarkedNullable -> null 151 | input.body == null -> throw ApiException("no request body present", "REQUEST_BODY_MISSING", 400) 152 | input.body is String && handler.requestType.classifier as KClass<*> == String::class -> input.body 153 | else -> deserializationHandlerChain.deserialize(input, handler.requestType) 154 | } 155 | 156 | private fun handleNonDirectMatch( 157 | defaultContentType: MediaType, 158 | matchResults: List, 159 | input: APIGatewayProxyRequestEvent, 160 | ): APIGatewayProxyResponseEvent { 161 | // no direct match 162 | val apiException = 163 | when { 164 | matchResults.any { it.matchPath && it.matchMethod && !it.matchContentType } -> 165 | ApiException( 166 | httpResponseStatus = 415, 167 | message = "Unsupported Media Type", 168 | code = "UNSUPPORTED_MEDIA_TYPE", 169 | ) 170 | matchResults.any { it.matchPath && it.matchMethod && !it.matchAcceptType } -> 171 | ApiException( 172 | httpResponseStatus = 406, 173 | message = "Not Acceptable", 174 | code = "NOT_ACCEPTABLE", 175 | ) 176 | matchResults.any { it.matchPath && !it.matchMethod } -> 177 | ApiException( 178 | httpResponseStatus = 405, 179 | message = "Method Not Allowed", 180 | code = "METHOD_NOT_ALLOWED", 181 | ) 182 | else -> 183 | ApiException( 184 | httpResponseStatus = 404, 185 | message = "Not found", 186 | code = "NOT_FOUND", 187 | ) 188 | } 189 | return createResponse( 190 | contentType = input.acceptedMediaTypes().firstOrNull() ?: defaultContentType, 191 | response = apiException.toResponseEntity(this::createErrorBody), 192 | ) 193 | } 194 | 195 | /** 196 | * Customize the format of an api error 197 | */ 198 | open fun createErrorBody(error: ApiError): Any = error 199 | 200 | /** 201 | * Customize the format of an unprocessable entity error 202 | */ 203 | open fun createUnprocessableEntityErrorBody(errors: List): Any = errors 204 | 205 | private fun createUnprocessableEntityErrorBody(error: UnprocessableEntityError): Any = createUnprocessableEntityErrorBody(listOf(error)) 206 | 207 | /** 208 | * Hook to customize the way non-ApiExceptions are converted to ResponseEntity. 209 | * 210 | * Some common exceptions are already handled in the default implementation. 211 | */ 212 | open fun exceptionToResponseEntity(ex: Exception) = 213 | when (ex) { 214 | is JsonParseException -> 215 | ResponseEntity( 216 | 422, 217 | createUnprocessableEntityErrorBody( 218 | UnprocessableEntityError( 219 | message = "INVALID_ENTITY", 220 | code = "ENTITY", 221 | path = "", 222 | details = 223 | mapOf( 224 | "payload" to ex.requestPayloadAsString.orEmpty(), 225 | "message" to ex.message.orEmpty(), 226 | ), 227 | ), 228 | ), 229 | ) 230 | is InvalidDefinitionException -> 231 | ResponseEntity( 232 | 422, 233 | createUnprocessableEntityErrorBody( 234 | UnprocessableEntityError( 235 | message = "INVALID_FIELD_FORMAT", 236 | code = "FIELD", 237 | path = 238 | ex.path 239 | .last() 240 | .fieldName 241 | .orEmpty(), 242 | details = 243 | mapOf( 244 | "cause" to ex.cause?.message.orEmpty(), 245 | "message" to ex.message.orEmpty(), 246 | ), 247 | ), 248 | ), 249 | ) 250 | is InvalidFormatException -> 251 | ResponseEntity( 252 | 422, 253 | createUnprocessableEntityErrorBody( 254 | UnprocessableEntityError( 255 | message = "INVALID_FIELD_FORMAT", 256 | code = "FIELD", 257 | path = 258 | ex.path 259 | .last() 260 | .fieldName 261 | .orEmpty(), 262 | ), 263 | ), 264 | ) 265 | is MissingKotlinParameterException -> 266 | ResponseEntity( 267 | 422, 268 | createUnprocessableEntityErrorBody( 269 | UnprocessableEntityError( 270 | message = "MISSING_REQUIRED_FIELDS", 271 | code = "FIELD", 272 | path = ex.parameter.name.orEmpty(), 273 | ), 274 | ), 275 | ) 276 | else -> ResponseEntity(500, createErrorBody(ApiError(ex.message.orEmpty(), "INTERNAL_SERVER_ERROR"))) 277 | } 278 | 279 | open fun createResponse( 280 | contentType: MediaType, 281 | response: ResponseEntity, 282 | ): APIGatewayProxyResponseEvent = 283 | when (response.body != null && serializationHandlerChain.supports(contentType, response.body)) { 284 | true -> contentType 285 | false -> MediaType.parse(router.defaultContentType) 286 | }.let { finalContentType -> 287 | APIGatewayProxyResponseEvent() 288 | .withStatusCode(response.statusCode) 289 | .withHeaders(response.headers.toMutableMap().apply { put("Content-Type", finalContentType.toString()) }) 290 | .withBody( 291 | response.body?.let { 292 | serializationHandlerChain.serialize(finalContentType, it as Any) 293 | }, 294 | ) 295 | } 296 | 297 | companion object { 298 | val log: Logger = LoggerFactory.getLogger(RequestHandler::class.java) 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /router/src/main/kotlin/io/moia/router/RequestPredicate.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 20 | import com.google.common.net.MediaType 21 | import isCompatibleWith 22 | 23 | interface RequestPredicate { 24 | fun consuming(vararg mediaTypes: String): RequestPredicate 25 | 26 | fun producing(vararg mediaTypes: String): RequestPredicate 27 | 28 | fun requiringPermissions(vararg permissions: String): RequestPredicate 29 | 30 | fun match(request: APIGatewayProxyRequestEvent): RequestMatchResult 31 | 32 | fun matchedAcceptType(acceptedMediaTypes: List): MediaType? 33 | 34 | val pathPattern: String 35 | 36 | val method: String 37 | var consumes: Set 38 | var produces: Set 39 | 40 | var requiredPermissions: Set 41 | } 42 | 43 | open class RequestPredicateImpl( 44 | override val method: String, 45 | override val pathPattern: String, 46 | override var produces: Set, 47 | override var consumes: Set, 48 | ) : RequestPredicate { 49 | override var requiredPermissions: Set = emptySet() 50 | 51 | override fun consuming(vararg mediaTypes: String): RequestPredicate { 52 | consumes = mediaTypes.toSet() 53 | return this 54 | } 55 | 56 | override fun producing(vararg mediaTypes: String): RequestPredicate { 57 | produces = mediaTypes.toSet() 58 | return this 59 | } 60 | 61 | /** 62 | * Register required permissions for this route. 63 | * The RequestHandler checks if any of the given permissions are found on a request. 64 | */ 65 | override fun requiringPermissions(vararg permissions: String): RequestPredicate { 66 | requiredPermissions = permissions.toSet() 67 | return this 68 | } 69 | 70 | override fun match(request: APIGatewayProxyRequestEvent) = 71 | RequestMatchResult( 72 | matchPath = pathMatches(request), 73 | matchMethod = methodMatches(request), 74 | matchAcceptType = acceptMatches(request.acceptedMediaTypes()), 75 | matchContentType = contentTypeMatches(request.contentType()), 76 | ) 77 | 78 | private fun pathMatches(request: APIGatewayProxyRequestEvent) = request.path?.let { UriTemplate.from(pathPattern).matches(it) } ?: false 79 | 80 | private fun methodMatches(request: APIGatewayProxyRequestEvent) = method.equals(request.httpMethod, true) 81 | 82 | /** 83 | * Find the media type that is compatible with the one the client requested out of the ones that the the handler can produce 84 | * Talking into account that an accept header can contain multiple media types (e.g. application/xhtml+xml, application/json) 85 | */ 86 | override fun matchedAcceptType(acceptedMediaTypes: List) = 87 | produces 88 | .map { MediaType.parse(it) } 89 | .firstOrNull { acceptedMediaTypes.any { acceptedType -> it.isCompatibleWith(acceptedType) } } 90 | 91 | private fun acceptMatches(acceptedMediaTypes: List) = matchedAcceptType(acceptedMediaTypes) != null 92 | 93 | private fun contentTypeMatches(contentType: String?) = 94 | when { 95 | consumes.isEmpty() -> true 96 | contentType == null -> false 97 | else -> consumes.any { MediaType.parse(contentType).isCompatibleWith(MediaType.parse(it)) } 98 | } 99 | } 100 | 101 | data class RequestMatchResult( 102 | val matchPath: Boolean = false, 103 | val matchMethod: Boolean = false, 104 | val matchAcceptType: Boolean = false, 105 | val matchContentType: Boolean = false, 106 | ) { 107 | val match 108 | get() = matchPath && matchMethod && matchAcceptType && matchContentType 109 | } 110 | -------------------------------------------------------------------------------- /router/src/main/kotlin/io/moia/router/ResponseEntity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import java.net.URI 20 | 21 | data class ResponseEntity( 22 | val statusCode: Int, 23 | val body: T? = null, 24 | val headers: Map = emptyMap(), 25 | ) { 26 | companion object { 27 | fun ok( 28 | body: T? = null, 29 | headers: Map = emptyMap(), 30 | ) = ResponseEntity(200, body, headers) 31 | 32 | fun created( 33 | body: T? = null, 34 | location: URI? = null, 35 | headers: Map = emptyMap(), 36 | ) = ResponseEntity(201, body, if (location == null) headers else headers + ("location" to location.toString())) 37 | 38 | fun accepted( 39 | body: T? = null, 40 | headers: Map = emptyMap(), 41 | ) = ResponseEntity(202, body, headers) 42 | 43 | fun noContent(headers: Map = emptyMap()) = ResponseEntity(204, null, headers) 44 | 45 | fun badRequest( 46 | body: T? = null, 47 | headers: Map = emptyMap(), 48 | ) = ResponseEntity(400, body, headers) 49 | 50 | fun notFound( 51 | body: T? = null, 52 | headers: Map = emptyMap(), 53 | ) = ResponseEntity(404, body, headers) 54 | 55 | fun unprocessableEntity( 56 | body: T? = null, 57 | headers: Map = emptyMap(), 58 | ) = ResponseEntity(422, body, headers) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /router/src/main/kotlin/io/moia/router/Router.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 20 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent.ProxyRequestContext 21 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent 22 | import kotlin.reflect.KType 23 | import kotlin.reflect.typeOf 24 | 25 | typealias PredicateFactory = (String, String, Set, Set) -> RequestPredicate 26 | 27 | @Suppress("FunctionName") 28 | class Router( 29 | private val predicateFactory: PredicateFactory, 30 | ) { 31 | val routes = mutableListOf>() 32 | 33 | var defaultConsuming = setOf("application/json") 34 | var defaultProducing = setOf("application/json") 35 | 36 | var defaultContentType = "application/json" 37 | 38 | var filter: Filter = Filter.NoOp 39 | 40 | inline fun GET( 41 | pattern: String, 42 | crossinline handlerFunction: HandlerFunction, 43 | ) = defaultRequestPredicate(pattern, "GET", HandlerFunctionWrapper.invoke(handlerFunction), emptySet()) 44 | 45 | inline fun POST( 46 | pattern: String, 47 | crossinline handlerFunction: HandlerFunction, 48 | ) = defaultRequestPredicate(pattern, "POST", HandlerFunctionWrapper.invoke(handlerFunction)) 49 | 50 | inline fun PUT( 51 | pattern: String, 52 | crossinline handlerFunction: HandlerFunction, 53 | ) = defaultRequestPredicate(pattern, "PUT", HandlerFunctionWrapper.invoke(handlerFunction)) 54 | 55 | inline fun DELETE( 56 | pattern: String, 57 | crossinline handlerFunction: HandlerFunction, 58 | ) = defaultRequestPredicate(pattern, "DELETE", HandlerFunctionWrapper.invoke(handlerFunction), emptySet()) 59 | 60 | inline fun PATCH( 61 | pattern: String, 62 | crossinline handlerFunction: HandlerFunction, 63 | ) = defaultRequestPredicate(pattern, "PATCH", HandlerFunctionWrapper.invoke(handlerFunction)) 64 | 65 | fun defaultRequestPredicate( 66 | pattern: String, 67 | method: String, 68 | handlerFunction: HandlerFunctionWrapper, 69 | consuming: Set = defaultConsuming, 70 | ) = predicateFactory(method, pattern, consuming, defaultProducing) 71 | .also { routes += RouterFunction(it, handlerFunction) } 72 | 73 | companion object { 74 | fun defaultPredicateFactory( 75 | method: String, 76 | pattern: String, 77 | consuming: Set, 78 | producing: Set, 79 | ): RequestPredicate = 80 | RequestPredicateImpl( 81 | method = method, 82 | pathPattern = pattern, 83 | consumes = consuming, 84 | produces = producing, 85 | ) 86 | 87 | fun router(routes: Router.() -> Unit) = Router(Router::defaultPredicateFactory).apply(routes) 88 | 89 | fun router( 90 | factory: PredicateFactory, 91 | routes: Router.() -> Unit, 92 | ) = Router(factory).apply(routes) 93 | } 94 | } 95 | 96 | interface Filter : (APIGatewayRequestHandlerFunction) -> APIGatewayRequestHandlerFunction { 97 | companion object { 98 | operator fun invoke(fn: (APIGatewayRequestHandlerFunction) -> APIGatewayRequestHandlerFunction): Filter = 99 | object : 100 | Filter { 101 | override operator fun invoke(next: APIGatewayRequestHandlerFunction): APIGatewayRequestHandlerFunction = fn(next) 102 | } 103 | } 104 | } 105 | 106 | val Filter.Companion.NoOp: Filter get() = Filter { next -> { next(it) } } 107 | 108 | fun Filter.then(next: Filter): Filter = Filter { this(next(it)) } 109 | 110 | fun Filter.then(next: APIGatewayRequestHandlerFunction): APIGatewayRequestHandlerFunction = { this(next)(it) } 111 | 112 | typealias APIGatewayRequestHandlerFunction = (APIGatewayProxyRequestEvent) -> APIGatewayProxyResponseEvent 113 | typealias HandlerFunction = (request: Request) -> ResponseEntity 114 | 115 | abstract class HandlerFunctionWrapper { 116 | abstract val requestType: KType 117 | abstract val responseType: KType 118 | 119 | abstract val handlerFunction: HandlerFunction 120 | 121 | companion object { 122 | inline operator fun invoke(crossinline handler: HandlerFunction): HandlerFunctionWrapper { 123 | val requestType = typeOf() 124 | val responseType = typeOf() 125 | return object : HandlerFunctionWrapper() { 126 | override val requestType: KType = requestType 127 | override val responseType: KType = responseType 128 | override val handlerFunction: HandlerFunction = { request -> handler.invoke(request) } 129 | } 130 | } 131 | } 132 | } 133 | 134 | class RouterFunction( 135 | val requestPredicate: RequestPredicate, 136 | val handler: HandlerFunctionWrapper, 137 | ) { 138 | override fun toString(): String = "RouterFunction(requestPredicate=$requestPredicate)" 139 | } 140 | 141 | data class Request( 142 | val apiRequest: APIGatewayProxyRequestEvent, 143 | val body: I, 144 | val pathPattern: String = apiRequest.path, 145 | ) { 146 | val pathParameters by lazy { UriTemplate.from(pathPattern).extract(apiRequest.path) } 147 | val queryParameters: Map? by lazy { apiRequest.queryStringParameters } 148 | val multiValueQueryStringParameters: Map>? by lazy { apiRequest.multiValueQueryStringParameters } 149 | val requestContext: ProxyRequestContext by lazy { apiRequest.requestContext } 150 | 151 | fun getPathParameter(name: String): String = pathParameters[name] ?: error("Could not find path parameter '$name") 152 | 153 | fun getQueryParameter(name: String): String? = queryParameters?.get(name) 154 | 155 | fun getMultiValueQueryStringParameter(name: String): List? = multiValueQueryStringParameters?.get(name) 156 | 157 | fun getJwtCognitoUsername(): String? = (JwtAccessor(this.apiRequest).extractJwtClaims()?.get("cognito:username") as? String) 158 | } 159 | -------------------------------------------------------------------------------- /router/src/main/kotlin/io/moia/router/SerializationHandler.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import com.fasterxml.jackson.databind.ObjectMapper 20 | import com.google.common.net.MediaType 21 | import isCompatibleWith 22 | 23 | interface SerializationHandler { 24 | fun supports( 25 | acceptHeader: MediaType, 26 | body: Any, 27 | ): Boolean 28 | 29 | fun serialize( 30 | acceptHeader: MediaType, 31 | body: Any, 32 | ): String 33 | } 34 | 35 | class SerializationHandlerChain( 36 | private val handlers: List, 37 | ) : SerializationHandler { 38 | override fun supports( 39 | acceptHeader: MediaType, 40 | body: Any, 41 | ): Boolean = handlers.any { it.supports(acceptHeader, body) } 42 | 43 | override fun serialize( 44 | acceptHeader: MediaType, 45 | body: Any, 46 | ): String = handlers.first { it.supports(acceptHeader, body) }.serialize(acceptHeader, body) 47 | } 48 | 49 | class JsonSerializationHandler( 50 | private val objectMapper: ObjectMapper, 51 | ) : SerializationHandler { 52 | private val json = MediaType.parse("application/json") 53 | private val jsonStructuredSuffixWildcard = MediaType.parse("application/*+json") 54 | 55 | override fun supports( 56 | acceptHeader: MediaType, 57 | body: Any, 58 | ): Boolean = json.isCompatibleWith(acceptHeader) || jsonStructuredSuffixWildcard.isCompatibleWith(acceptHeader) 59 | 60 | override fun serialize( 61 | acceptHeader: MediaType, 62 | body: Any, 63 | ): String = objectMapper.writeValueAsString(body) 64 | } 65 | 66 | class PlainTextSerializationHandler( 67 | val supportedAcceptTypes: List = listOf(MediaType.parse("text/*")), 68 | ) : SerializationHandler { 69 | override fun supports( 70 | acceptHeader: MediaType, 71 | body: Any, 72 | ): Boolean = supportedAcceptTypes.any { acceptHeader.isCompatibleWith(it) } 73 | 74 | override fun serialize( 75 | acceptHeader: MediaType, 76 | body: Any, 77 | ): String = body.toString() 78 | } 79 | -------------------------------------------------------------------------------- /router/src/main/kotlin/io/moia/router/UriTemplate.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import java.net.URLDecoder 20 | import java.util.regex.Pattern 21 | 22 | class UriTemplate private constructor( 23 | private val template: String, 24 | ) { 25 | private val templateRegex: Regex 26 | private val matches: Sequence 27 | private val parameterNames: List 28 | 29 | init { 30 | if (INVALID_GREEDY_PATH_VARIABLE_REGEX.matches(template)) { 31 | throw IllegalArgumentException("Greedy path variables (e.g. '{proxy+}' are only allowed at the end of the template") 32 | } 33 | matches = PATH_VARIABLE_REGEX.findAll(template) 34 | parameterNames = matches.map { it.groupValues[1] }.toList() 35 | templateRegex = 36 | template 37 | .replace( 38 | PATH_VARIABLE_REGEX, 39 | { notMatched -> Pattern.quote(notMatched) }, 40 | { matched -> 41 | // check for greedy path variables, e.g. '{proxy+}' 42 | if (matched.groupValues[1].endsWith("+")) { 43 | return@replace "(.+)" 44 | } 45 | if (matched.groupValues[2].isBlank()) "([^/]+)" else "(${matched.groupValues[2]})" 46 | }, 47 | ).toRegex() 48 | } 49 | 50 | companion object { 51 | private val PATH_VARIABLE_REGEX = "\\{([^}]+?)(?::([^}]+))?}".toRegex() 52 | private val INVALID_GREEDY_PATH_VARIABLE_REGEX = ".*\\{([^}]+?)(?::([^}]+))?\\+}.+".toRegex() 53 | 54 | // Removes query params 55 | fun from(template: String) = UriTemplate(template.split('?')[0].trimSlashes()) 56 | 57 | fun String.trimSlashes() = "^(/)?(.*?)(/)?$".toRegex().replace(this) { result -> result.groupValues[2] } 58 | } 59 | 60 | fun matches(uri: String): Boolean = templateRegex.matches(uri.trimSlashes()) 61 | 62 | fun extract(uri: String): Map = parameterNames.zip(templateRegex.findParameterValues(uri.trimSlashes())).toMap() 63 | 64 | private fun Regex.findParameterValues(uri: String): List = 65 | findAll(uri) 66 | .first() 67 | .groupValues 68 | .drop(1) 69 | .map { URLDecoder.decode(it, "UTF-8") } 70 | 71 | private fun String.replace( 72 | regex: Regex, 73 | notMatched: (String) -> String, 74 | matched: (MatchResult) -> String, 75 | ): String { 76 | val matches = regex.findAll(this) 77 | val builder = StringBuilder() 78 | var position = 0 79 | for (matchResult in matches) { 80 | val before = substring(position, matchResult.range.start) 81 | if (before.isNotEmpty()) builder.append(notMatched(before)) 82 | builder.append(matched(matchResult)) 83 | position = matchResult.range.endInclusive + 1 84 | } 85 | val after = substring(position, length) 86 | if (after.isNotEmpty()) builder.append(notMatched(after)) 87 | return builder.toString() 88 | } 89 | 90 | override fun toString(): String = template 91 | } 92 | -------------------------------------------------------------------------------- /router/src/main/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{yyyy-MM-dd HH:mm:ss} %X{AWSRequestId} %-5p %c{1}:%L - %m%n 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /router/src/test/kotlin/io/moia/router/APIGatewayProxyEventExtensionsTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 20 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent 21 | import org.assertj.core.api.BDDAssertions.then 22 | import org.junit.jupiter.api.Test 23 | 24 | class APIGatewayProxyEventExtensionsTest { 25 | @Test 26 | fun `should add location header`() { 27 | val request = 28 | GET() 29 | .withHeader("Host", "example.com") 30 | .withHeader("X-Forwarded-Proto", "http") 31 | .withHeader("X-Forwarded-Port", "8080") 32 | 33 | val response = 34 | APIGatewayProxyResponseEvent() 35 | .withLocationHeader(request, "/some/path") 36 | 37 | then(response.location()).isEqualTo("http://example.com:8080/some/path") 38 | } 39 | 40 | @Test 41 | fun `should add location header with default host and proto and without port`() { 42 | val request = GET() 43 | 44 | val response = 45 | APIGatewayProxyResponseEvent() 46 | .withLocationHeader(request, "/some/path") 47 | 48 | then(response.location()).isEqualTo("http://localhost/some/path") 49 | } 50 | 51 | @Test 52 | fun `should omit default https port`() { 53 | val request = 54 | GET() 55 | .withHeader("Host", "example.com") 56 | .withHeader("X-Forwarded-Proto", "https") 57 | .withHeader("X-Forwarded-Port", "443") 58 | 59 | val location = request.location("some/path") 60 | 61 | then(location.toString()).isEqualTo("https://example.com/some/path") 62 | } 63 | 64 | @Test 65 | fun `should omit default http port`() { 66 | val request = 67 | GET() 68 | .withHeader("Host", "example.com") 69 | .withHeader("X-Forwarded-Proto", "http") 70 | .withHeader("X-Forwarded-Port", "80") 71 | 72 | val location = request.location("/some/path") 73 | 74 | then(location.toString()).isEqualTo("http://example.com/some/path") 75 | } 76 | 77 | @Test 78 | fun `header class should work as expected with APIGatewayProxyRequestEvent`() { 79 | val request = APIGatewayProxyRequestEvent().withHeader(Header("foo", "bar")) 80 | 81 | then(request.headers["foo"]).isEqualTo("bar") 82 | } 83 | 84 | @Test 85 | fun `header class should work as expected with APIGatewayProxyResponseEvent`() { 86 | val request = APIGatewayProxyResponseEvent().withHeader(Header("foo", "bar")) 87 | 88 | then(request.headers["foo"]).isEqualTo("bar") 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /router/src/test/kotlin/io/moia/router/ApiRequestTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import assertk.assertThat 20 | import assertk.assertions.isEqualTo 21 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 22 | import org.junit.jupiter.api.Test 23 | 24 | class ApiRequestTest { 25 | @Test 26 | fun `should match header`() { 27 | val request = APIGatewayProxyRequestEvent().withHeaders(mapOf("Accept" to "application/json")) 28 | 29 | assertThat(request.acceptHeader()).isEqualTo("application/json") 30 | } 31 | 32 | @Test 33 | fun `should match header lowercase`() { 34 | val request = APIGatewayProxyRequestEvent().withHeaders(mapOf("accept" to "application/json")) 35 | 36 | assertThat(request.acceptHeader()).isEqualTo("application/json") 37 | } 38 | 39 | @Test 40 | fun `should match header uppercase`() { 41 | val request = APIGatewayProxyRequestEvent().withHeaders(mapOf("ACCEPT" to "application/json")) 42 | 43 | assertThat(request.acceptHeader()).isEqualTo("application/json") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /router/src/test/kotlin/io/moia/router/JsonDeserializationHandlerTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 20 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 21 | import org.junit.jupiter.api.Assertions.assertFalse 22 | import org.junit.jupiter.api.Assertions.assertTrue 23 | import org.junit.jupiter.api.Test 24 | 25 | class JsonDeserializationHandlerTest { 26 | val deserializationHandler = JsonDeserializationHandler(jacksonObjectMapper()) 27 | 28 | @Test 29 | fun `should support json`() { 30 | assertTrue( 31 | deserializationHandler.supports( 32 | APIGatewayProxyRequestEvent() 33 | .withHeader("content-type", "application/json"), 34 | ), 35 | ) 36 | assertTrue( 37 | deserializationHandler.supports( 38 | APIGatewayProxyRequestEvent() 39 | .withHeader("content-type", "application/vnd.moia.v1+json"), 40 | ), 41 | ) 42 | } 43 | 44 | @Test 45 | fun `should not support anything else than json`() { 46 | assertFalse( 47 | deserializationHandler.supports( 48 | APIGatewayProxyRequestEvent() 49 | .withHeader("content-type", "image/png"), 50 | ), 51 | ) 52 | assertFalse( 53 | deserializationHandler.supports( 54 | APIGatewayProxyRequestEvent() 55 | .withHeader("content-type", "text/plain"), 56 | ), 57 | ) 58 | } 59 | 60 | @Test 61 | fun `should support json with UTF-8 charset parameter`() { 62 | assertTrue( 63 | deserializationHandler.supports( 64 | APIGatewayProxyRequestEvent() 65 | .withHeader("content-type", "application/json; charset=UTF-8"), 66 | ), 67 | ) 68 | assertTrue( 69 | deserializationHandler.supports( 70 | APIGatewayProxyRequestEvent() 71 | .withHeader("content-type", "application/vnd.moia.v1+json; charset=UTF-8"), 72 | ), 73 | ) 74 | } 75 | 76 | @Test 77 | fun `should not support json with other charset parameter`() { 78 | assertFalse( 79 | deserializationHandler.supports( 80 | APIGatewayProxyRequestEvent() 81 | .withHeader("content-type", "application/json; charset=UTF-16"), 82 | ), 83 | ) 84 | assertFalse( 85 | deserializationHandler.supports( 86 | APIGatewayProxyRequestEvent() 87 | .withHeader("content-type", "application/vnd.moia.v1+json; charset=UTF-16"), 88 | ), 89 | ) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /router/src/test/kotlin/io/moia/router/JwtPermissionHandlerTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 20 | import org.assertj.core.api.BDDAssertions.then 21 | import org.junit.jupiter.api.Test 22 | 23 | @Suppress("ktlint:standard:max-line-length") 24 | class JwtPermissionHandlerTest { 25 | /* 26 | { 27 | "sub": "1234567890", 28 | "name": "John Doe", 29 | "iat": 1516239022, 30 | "scope": "one two" 31 | } 32 | */ 33 | val jwtWithScopeClaimSpace = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJzY29wZSI6Im9uZSB0d28ifQ.2tPrDymXDejHfVjNlVh4XUj22ZuDrKHP6dvWN7JNAWY" 34 | 35 | /* 36 | { 37 | "sub": "1234567890", 38 | "name": "John Doe", 39 | "iat": 1516239022, 40 | "userRights": "one, two" 41 | } 42 | */ 43 | val jwtWithCustomClaimAndSeparator = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJ1c2VyUmlnaHRzIjoib25lLCB0d28ifQ.49yk0fq39zMF77ZLJsXH_6D6I3iSDpy-Qk3vZ_PssIY" 44 | 45 | @Test 46 | fun `should extract permissions from standard JWT contained in bearer auth header`() { 47 | val handler = permissionHandler("Bearer $jwtWithScopeClaimSpace") 48 | 49 | thenRecognizesRequiredPermissions(handler) 50 | } 51 | 52 | @Test 53 | fun `should extract permissions from standard JWT contained in auth header`() { 54 | val handler = permissionHandler(jwtWithScopeClaimSpace) 55 | 56 | thenRecognizesRequiredPermissions(handler) 57 | } 58 | 59 | @Test 60 | fun `should extract permissions from custom permissions claim`() { 61 | val handler = 62 | JwtPermissionHandler( 63 | accessor = 64 | JwtAccessor( 65 | APIGatewayProxyRequestEvent() 66 | .withHeader("Authorization", jwtWithCustomClaimAndSeparator), 67 | ), 68 | permissionsClaim = "userRights", 69 | permissionSeparator = ",", 70 | ) 71 | 72 | thenRecognizesRequiredPermissions(handler) 73 | } 74 | 75 | @Test 76 | fun `should return true when no permissions are required`() { 77 | val handler = permissionHandler(jwtWithScopeClaimSpace) 78 | 79 | val result = handler.hasAnyRequiredPermission(emptySet()) 80 | 81 | then(result).isTrue() 82 | } 83 | 84 | @Test 85 | fun `should work for missing header`() { 86 | val handler = JwtPermissionHandler(JwtAccessor(APIGatewayProxyRequestEvent())) 87 | 88 | then(handler.extractPermissions()).isEmpty() 89 | } 90 | 91 | @Test 92 | fun `should work for not jwt auth header`() { 93 | val handler = permissionHandler("a.b.c") 94 | 95 | then(handler.extractPermissions()).isEmpty() 96 | } 97 | 98 | private fun thenRecognizesRequiredPermissions(handler: JwtPermissionHandler) { 99 | then(handler.hasAnyRequiredPermission(setOf("one"))).isTrue() 100 | then(handler.hasAnyRequiredPermission(setOf("two"))).isTrue() 101 | then(handler.hasAnyRequiredPermission(setOf("nope"))).isFalse() 102 | } 103 | 104 | private fun permissionHandler(authHeader: String) = 105 | JwtPermissionHandler( 106 | JwtAccessor( 107 | APIGatewayProxyRequestEvent() 108 | .withHeader("Authorization", authHeader), 109 | ), 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /router/src/test/kotlin/io/moia/router/MediaTypeTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import com.google.common.net.MediaType 20 | import isCompatibleWith 21 | import org.assertj.core.api.BDDAssertions.then 22 | import org.junit.jupiter.api.Test 23 | 24 | class MediaTypeTest { 25 | @Test 26 | fun `should match`() { 27 | then(MediaType.parse("application/json").isCompatibleWith(MediaType.parse("application/json"))).isTrue() 28 | } 29 | 30 | @Test 31 | fun `should match subtype wildcard`() { 32 | then(MediaType.parse("application/json").isCompatibleWith(MediaType.parse("application/*"))).isTrue() 33 | } 34 | 35 | @Test 36 | fun `should not match subtype wildcard in different tpye`() { 37 | then(MediaType.parse("application/json").isCompatibleWith(MediaType.parse("image/*"))).isFalse() 38 | } 39 | 40 | @Test 41 | fun `should match wildcard`() { 42 | then(MediaType.parse("application/json").isCompatibleWith(MediaType.parse("*/*"))).isTrue() 43 | } 44 | 45 | @Test 46 | fun `should match wildcard structured syntax suffix`() { 47 | then(MediaType.parse("application/*+json").isCompatibleWith(MediaType.parse("application/vnd.moia+json"))).isTrue() 48 | then(MediaType.parse("application/*+json").isCompatibleWith(MediaType.parse("application/vnd.moia.v1+json"))).isTrue() 49 | } 50 | 51 | @Test 52 | fun `should not match wildcard structured syntax suffix on non suffix type`() { 53 | then(MediaType.parse("application/*+json").isCompatibleWith(MediaType.parse("application/json"))).isFalse() 54 | } 55 | 56 | @Test 57 | fun `should not match wildcard structured syntax suffix on differnt suffix`() { 58 | then(MediaType.parse("application/*+json").isCompatibleWith(MediaType.parse("application/*+x-protobuf"))).isFalse() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /router/src/test/kotlin/io/moia/router/NoOpPermissionHandlerTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import org.assertj.core.api.BDDAssertions.then 20 | import org.junit.jupiter.api.Test 21 | 22 | class NoOpPermissionHandlerTest { 23 | @Test 24 | fun `should always return true`() { 25 | val handler = NoOpPermissionHandler() 26 | 27 | then(handler.hasAnyRequiredPermission(setOf("any"))).isTrue() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /router/src/test/kotlin/io/moia/router/PlainTextDeserializationHandlerTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 20 | import org.junit.jupiter.api.Assertions.assertEquals 21 | import org.junit.jupiter.api.Assertions.assertFalse 22 | import org.junit.jupiter.api.Assertions.assertTrue 23 | import org.junit.jupiter.api.Test 24 | 25 | class PlainTextDeserializationHandlerTest { 26 | @Test 27 | fun `should support text`() { 28 | assertTrue( 29 | PlainTextDeserializationHandler.supports( 30 | APIGatewayProxyRequestEvent() 31 | .withHeader("content-type", "text/plain"), 32 | ), 33 | ) 34 | assertTrue( 35 | PlainTextDeserializationHandler.supports( 36 | APIGatewayProxyRequestEvent() 37 | .withHeader("content-type", "text/csv"), 38 | ), 39 | ) 40 | } 41 | 42 | @Test 43 | fun `should not support anything else than text`() { 44 | assertFalse( 45 | PlainTextDeserializationHandler.supports( 46 | APIGatewayProxyRequestEvent() 47 | .withHeader("content-type", "image/png"), 48 | ), 49 | ) 50 | assertFalse( 51 | PlainTextDeserializationHandler.supports( 52 | APIGatewayProxyRequestEvent() 53 | .withHeader("content-type", "application/json"), 54 | ), 55 | ) 56 | } 57 | 58 | @Test 59 | fun `should not support anything when content type is null`() { 60 | assertFalse(PlainTextDeserializationHandler.supports(APIGatewayProxyRequestEvent())) 61 | } 62 | 63 | @Test 64 | fun `should return body`() { 65 | val request = APIGatewayProxyRequestEvent().withBody("some") 66 | val result = PlainTextDeserializationHandler.deserialize(request, null) 67 | 68 | assertEquals(request.body, result) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /router/src/test/kotlin/io/moia/router/PlainTextSerializationHandlerTest.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router 2 | 3 | import com.google.common.net.MediaType 4 | import org.junit.jupiter.api.Assertions.assertEquals 5 | import org.junit.jupiter.api.Assertions.assertFalse 6 | import org.junit.jupiter.api.Assertions.assertTrue 7 | import org.junit.jupiter.api.Test 8 | 9 | class PlainTextSerializationHandlerTest { 10 | @Test 11 | fun `should support text`() { 12 | assertTrue(PlainTextSerializationHandler().supports(MediaType.parse("text/plain"), "some")) 13 | assertTrue(PlainTextSerializationHandler(listOf(MediaType.parse("text/csv"))).supports(MediaType.parse("text/csv"), "some")) 14 | } 15 | 16 | @Test 17 | fun `should not support anything else than text`() { 18 | assertFalse(PlainTextSerializationHandler().supports(MediaType.parse("application/json"), "some")) 19 | assertFalse(PlainTextSerializationHandler().supports(MediaType.parse("image/jpeg"), "some")) 20 | } 21 | 22 | @Test 23 | fun `should serialize string`() { 24 | assertEquals("some", PlainTextSerializationHandler().serialize(MediaType.parse("text/plain"), "some")) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /router/src/test/kotlin/io/moia/router/ResponseEntityTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import assertk.assertThat 20 | import assertk.assertions.isEqualTo 21 | import assertk.assertions.isNotEmpty 22 | import assertk.assertions.isNotNull 23 | import assertk.assertions.isNull 24 | import org.junit.jupiter.api.Test 25 | 26 | class ResponseEntityTest { 27 | private val body = "body" 28 | private val headers = 29 | mapOf( 30 | "content-type" to "text/plain", 31 | ) 32 | 33 | @Test 34 | fun `should process ok response`() { 35 | val response = ResponseEntity.ok(body, headers) 36 | 37 | assertThat(response.statusCode).isEqualTo(200) 38 | assertThat(response.headers).isNotEmpty() 39 | assertThat(response.body).isNotNull() 40 | } 41 | 42 | @Test 43 | fun `should process accepted response`() { 44 | val response = ResponseEntity.accepted(body, headers) 45 | 46 | assertThat(response.statusCode).isEqualTo(202) 47 | assertThat(response.headers).isNotEmpty() 48 | assertThat(response.body).isNotNull() 49 | } 50 | 51 | @Test 52 | fun `should process no content response`() { 53 | val response = ResponseEntity.noContent(headers) 54 | 55 | assertThat(response.statusCode).isEqualTo(204) 56 | assertThat(response.headers).isNotEmpty() 57 | assertThat(response.body).isNull() 58 | } 59 | 60 | @Test 61 | fun `should process bad request response`() { 62 | val response = ResponseEntity.badRequest(body, headers) 63 | 64 | assertThat(response.statusCode).isEqualTo(400) 65 | assertThat(response.headers).isNotEmpty() 66 | assertThat(response.body).isNotNull() 67 | } 68 | 69 | @Test 70 | fun `should process not found response`() { 71 | val response = ResponseEntity.notFound(body, headers) 72 | 73 | assertThat(response.statusCode).isEqualTo(404) 74 | assertThat(response.headers).isNotEmpty() 75 | assertThat(response.body).isNotNull() 76 | } 77 | 78 | @Test 79 | fun `should process unprocessable entity response`() { 80 | val response = ResponseEntity.unprocessableEntity(body, headers) 81 | 82 | assertThat(response.statusCode).isEqualTo(422) 83 | assertThat(response.headers).isNotEmpty() 84 | assertThat(response.body).isNotNull() 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /router/src/test/kotlin/io/moia/router/RouterTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import assertk.assertThat 20 | import assertk.assertions.hasSize 21 | import assertk.assertions.isEmpty 22 | import assertk.assertions.isEqualTo 23 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 24 | import io.moia.router.Router.Companion.router 25 | import org.assertj.core.api.BDDAssertions.then 26 | import org.junit.jupiter.api.Assertions.assertTrue 27 | import org.junit.jupiter.api.Test 28 | 29 | class RouterTest { 30 | @Test 31 | fun `should register get route with default accept header`() { 32 | val router = 33 | router { 34 | GET("/some") { r: Request -> 35 | ResponseEntity.ok("""{"hello": "world", "request":"${r.body}"}""") 36 | } 37 | } 38 | 39 | assertThat(router.routes).hasSize(1) 40 | with(router.routes.first().requestPredicate) { 41 | assertThat(method).isEqualTo("GET") 42 | assertThat(pathPattern).isEqualTo("/some") 43 | assertThat(consumes).isEmpty() 44 | assertThat(produces).isEqualTo(setOf("application/json")) 45 | } 46 | } 47 | 48 | @Test 49 | fun `should register routes`() { 50 | val router = 51 | router { 52 | PUT("/some") { _: Request -> ResponseEntity.ok("") } 53 | PATCH("/some") { _: Request -> ResponseEntity.ok("") } 54 | DELETE("/some") { _: Request -> ResponseEntity.ok("") } 55 | POST("/some") { _: Request -> ResponseEntity.ok("") } 56 | } 57 | 58 | then(router.routes.map { it.requestPredicate.method }).containsOnly("PUT", "PATCH", "DELETE", "POST") 59 | } 60 | 61 | @Test 62 | fun `should register post route with specific content types`() { 63 | val router = 64 | router { 65 | POST("/some") { r: Request -> 66 | ResponseEntity.ok("""{"hello": "world", "request":"${r.body}"}""") 67 | }.producing("text/plain") 68 | .consuming("text/plain") 69 | } 70 | 71 | assertThat(router.routes).hasSize(1) 72 | with(router.routes.first().requestPredicate) { 73 | assertThat(method).isEqualTo("POST") 74 | assertThat(pathPattern).isEqualTo("/some") 75 | assertThat(consumes).isEqualTo(setOf("text/plain")) 76 | assertThat(produces).isEqualTo(setOf("text/plain")) 77 | } 78 | } 79 | 80 | @Test 81 | fun `should register get route with custom default content types`() { 82 | val router = 83 | router { 84 | defaultConsuming = setOf("text/plain") 85 | defaultProducing = setOf("text/plain") 86 | 87 | POST("/some") { r: Request -> 88 | ResponseEntity.ok("""{"hello": "world", "request":"${r.body}"}""") 89 | } 90 | } 91 | 92 | assertThat(router.routes).hasSize(1) 93 | with(router.routes.first().requestPredicate) { 94 | assertThat(method).isEqualTo("POST") 95 | assertThat(pathPattern).isEqualTo("/some") 96 | assertThat(consumes).isEqualTo(setOf("text/plain")) 97 | assertThat(produces).isEqualTo(setOf("text/plain")) 98 | } 99 | } 100 | 101 | @Test 102 | fun `should handle greedy path variables successfully`() { 103 | val router = 104 | router { 105 | POST("/some/{proxy+}") { r: Request -> 106 | ResponseEntity.ok("""{"hello": "world", "request":"${r.body}"}""") 107 | } 108 | } 109 | assertThat(router.routes).hasSize(1) 110 | with(router.routes.first().requestPredicate) { 111 | assertTrue(UriTemplate.from(pathPattern).matches("/some/sub/sub/sub/path")) 112 | } 113 | } 114 | 115 | @Test 116 | fun `should not consume for a deletion route`() { 117 | val router = 118 | router { 119 | DELETE("/delete-me") { _: Request -> 120 | ResponseEntity.ok(null) 121 | } 122 | } 123 | with(router.routes.first().requestPredicate) { 124 | assertThat(consumes).isEqualTo(setOf()) 125 | } 126 | } 127 | 128 | @Test 129 | fun `request should contain ProxyRequestContext`() { 130 | val claims = 131 | mapOf( 132 | "foobar" to "foo", 133 | ) 134 | val context = 135 | APIGatewayProxyRequestEvent.ProxyRequestContext().apply { 136 | authorizer = mapOf("claims" to claims) 137 | } 138 | 139 | val request = 140 | Request( 141 | APIGatewayProxyRequestEvent() 142 | .withPath("/some-other") 143 | .withHttpMethod("GET") 144 | .withHeaders(mapOf("Accept" to "application/json")) 145 | .withRequestContext(context), 146 | Unit, 147 | ) 148 | assertThat(request.requestContext.authorizer!!["claims"]).isEqualTo(claims) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /router/src/test/kotlin/io/moia/router/UriTemplateTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 MOIA GmbH 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations under the License. 14 | * 15 | */ 16 | 17 | package io.moia.router 18 | 19 | import org.assertj.core.api.BDDAssertions.then 20 | import org.junit.jupiter.api.assertThrows 21 | import org.junit.jupiter.params.ParameterizedTest 22 | import org.junit.jupiter.params.provider.Arguments 23 | import org.junit.jupiter.params.provider.MethodSource 24 | import java.util.UUID 25 | 26 | class UriTemplateTest { 27 | @ParameterizedTest 28 | @MethodSource("matchTestParams") 29 | fun `match template`( 30 | uriTemplate: String, 31 | matchTemplate: String, 32 | expectedResult: Boolean, 33 | ) { 34 | then(UriTemplate.from(uriTemplate).matches(matchTemplate)).isEqualTo(expectedResult) 35 | } 36 | 37 | @ParameterizedTest 38 | @MethodSource("extractTestParams") 39 | fun `extract template`( 40 | uriTemplate: String, 41 | extractTemplate: String, 42 | expectedResult: Map, 43 | ) { 44 | then(UriTemplate.from(uriTemplate).extract(extractTemplate)).isEqualTo(expectedResult) 45 | } 46 | 47 | @ParameterizedTest 48 | @MethodSource("notAllowedGreedyPathTemplates") 49 | fun `should throw exception for greedy path variables at the wrong place`(testedValue: String) { 50 | assertThrows("Greedy path variables (e.g. '{proxy+}' are only allowed at the end of the template") { 51 | UriTemplate.from(testedValue) 52 | } 53 | } 54 | 55 | companion object { 56 | @JvmStatic 57 | @Suppress("unused") 58 | fun matchTestParams() = 59 | listOf( 60 | Arguments.of("/some", "/some", true, "should match without parameter"), 61 | Arguments.of("/some", "/some-other", false, "should not match simple"), 62 | Arguments.of("/some/{id}", "/some/${UUID.randomUUID()}", true, "should match with parameter-1"), 63 | Arguments.of("/some/{id}/other", "/some/${UUID.randomUUID()}/other", true, "should match with parameter-2"), 64 | Arguments.of("/some/{id}", "/some-other/${UUID.randomUUID()}", false, "should not match with parameter-1"), 65 | Arguments.of( 66 | "/some/{id}/other", 67 | "/some/${UUID.randomUUID()}/other-test", 68 | false, 69 | "should not match with parameter-2", 70 | ), 71 | Arguments.of("/some?a=1", "/some", true, "should match with query parameter 1"), 72 | Arguments.of("/some?a=1&b=2", "/some", true, "should match with query parameter 2"), 73 | Arguments.of( 74 | "/some/{id}?a=1", 75 | "/some/${UUID.randomUUID()}", 76 | true, 77 | "should match with path parameter and query parameter 1", 78 | ), 79 | Arguments.of( 80 | "/some/{id}/other?a=1&b=2", 81 | "/some/${UUID.randomUUID()}/other", 82 | true, 83 | "should match with path parameter and query parameter 2", 84 | ), 85 | Arguments.of( 86 | "/some/{proxy+}", 87 | "/some/sub/sub/sub/path", 88 | true, 89 | "should handle greedy path variables successfully", 90 | ), 91 | ) 92 | 93 | @JvmStatic 94 | @Suppress("unused") 95 | fun extractTestParams() = 96 | listOf( 97 | Arguments.of("/some", "/some", emptyMap(), "should extract parameters-1"), 98 | Arguments.of( 99 | "/some/{first}/other/{second}", 100 | "/some/first-value/other/second-value", 101 | mapOf("first" to "first-value", "second" to "second-value"), 102 | "should extract parameters 2", 103 | ), 104 | ) 105 | 106 | @JvmStatic 107 | @Suppress("unused") 108 | fun notAllowedGreedyPathTemplates() = 109 | listOf( 110 | "/some/{proxy+}/and/{variable}/error", 111 | "/{proxy+}/some/and/{variable}/error", 112 | "/here/some/and/{proxy+}/{variable}", 113 | "/here/some/and/{proxy+}/error", // FIXME: it should throw exception 114 | "/here/some/and//good/good/{proxy+}/bad/bad/bad", // FIXME: it should throw exception 115 | "/{proxy+}/{id}", 116 | "/{proxy+}/whatever", 117 | ) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample-proto/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moia-oss/lambda-kotlin-request-router/d9793e80364cd4df43069e301f05d4c1fa04adb2/samples/lambda-kotlin-request-router-sample-proto/.DS_Store -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample-proto/build.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar 3 | import com.github.jengelman.gradle.plugins.shadow.transformers.Log4j2PluginsCacheFileTransformer 4 | import com.google.protobuf.gradle.protobuf 5 | import com.google.protobuf.gradle.protoc 6 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 7 | import java.net.URI 8 | import java.util.concurrent.TimeUnit.SECONDS 9 | 10 | 11 | buildscript { 12 | repositories { 13 | mavenCentral() 14 | } 15 | } 16 | 17 | plugins { 18 | java 19 | kotlin("jvm") version "2.1.0" 20 | idea 21 | id("com.github.johnrengelman.shadow") version "8.1.1" 22 | id("org.jmailen.kotlinter") version "5.0.1" 23 | id("com.google.protobuf") version "0.9.4" 24 | } 25 | 26 | 27 | group = "com.github.mduesterhoeft" 28 | version = "1.0-SNAPSHOT" 29 | 30 | repositories { 31 | mavenCentral() 32 | maven { url = URI("https://jitpack.io") } 33 | } 34 | 35 | val proto = "4.29.3" 36 | dependencies { 37 | implementation(kotlin("stdlib")) 38 | implementation(kotlin("reflect")) 39 | 40 | implementation("io.moia.lambda-kotlin-request-router:router-protobuf:1.1.0") 41 | 42 | implementation("com.amazonaws:aws-lambda-java-core:1.2.3") 43 | implementation("com.amazonaws:aws-lambda-java-log4j2:1.6.0") 44 | implementation("com.fasterxml.jackson.core:jackson-databind:2.18.2") 45 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2") 46 | implementation("com.google.guava:guava:23.0") 47 | implementation("com.google.protobuf:protobuf-java:$proto") 48 | implementation("com.google.protobuf:protobuf-java-util:$proto") 49 | 50 | testImplementation("org.junit.jupiter:junit-jupiter-engine:5.11.4") 51 | } 52 | 53 | tasks { 54 | withType { 55 | kotlinOptions.jvmTarget = "17" 56 | } 57 | 58 | withType { 59 | useJUnitPlatform() 60 | } 61 | 62 | withType { 63 | archiveBaseName.set(project.name) 64 | archiveClassifier.set("") 65 | archiveVersion.set("") 66 | transform(Log4j2PluginsCacheFileTransformer::class.java) 67 | } 68 | 69 | val deploy by creating(Exec::class) { 70 | 71 | dependsOn("test", "shadowJar") 72 | commandLine("serverless", "deploy") 73 | } 74 | } 75 | 76 | configurations.all { 77 | resolutionStrategy.cacheChangingModulesFor(0, SECONDS) 78 | } 79 | 80 | protobuf { 81 | protoc { 82 | // The artifact spec for the Protobuf Compiler 83 | artifact = "com.google.protobuf:protoc:$proto" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample-proto/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moia-oss/lambda-kotlin-request-router/d9793e80364cd4df43069e301f05d4c1fa04adb2/samples/lambda-kotlin-request-router-sample-proto/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample-proto/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample-proto/gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample-proto/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample-proto/serverless.yml: -------------------------------------------------------------------------------- 1 | service: request-router-sample-proto # NOTE: update this with your service name 2 | 3 | provider: 4 | name: aws 5 | runtime: java8 6 | region: eu-west-1 7 | 8 | package: 9 | artifact: build/libs/lambda-kotlin-request-router-sample-proto.jar 10 | 11 | functions: 12 | my-handler: 13 | handler: io.moia.router.sample.MyRequestHandler 14 | events: 15 | - http: 16 | path: some 17 | method: get 18 | - http: 19 | path: some 20 | method: post 21 | -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample-proto/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'lambda-kotlin-request-router-sample-proto' 2 | -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample-proto/src/main/kotlin/io/moia/router/sample/MyRequestHandler.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.sample 2 | 3 | import io.moia.router.Request 4 | import io.moia.router.ResponseEntity 5 | import io.moia.router.Router.Companion.router 6 | import io.moia.router.proto.ProtoEnabledRequestHandler 7 | import io.moia.router.proto.sample.SampleOuterClass.Sample 8 | 9 | class MyRequestHandler : ProtoEnabledRequestHandler() { 10 | private val controller = SomeController() 11 | 12 | override val router = router { 13 | // functions can be externalized... 14 | GET("/some", controller::get) 15 | 16 | // simple handlers can also be declared inline 17 | POST("/some") { r: Request -> ResponseEntity.ok(r.body) } 18 | } 19 | } -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample-proto/src/main/kotlin/io/moia/router/sample/SomeController.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.sample 2 | 3 | import io.moia.router.Request 4 | import io.moia.router.ResponseEntity 5 | import io.moia.router.proto.sample.SampleOuterClass 6 | 7 | class SomeController { 8 | 9 | fun get(request: Request) = 10 | ResponseEntity.ok(SampleOuterClass.Sample.newBuilder().setHello("hello").build()) 11 | } -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample-proto/src/main/proto/Sample.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package io.moia.router.proto.sample; 4 | 5 | message Sample { 6 | string hello = 1; 7 | string request = 2; 8 | } -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample/build.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar 3 | import com.github.jengelman.gradle.plugins.shadow.transformers.Log4j2PluginsCacheFileTransformer 4 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 5 | import java.net.URI 6 | import java.util.concurrent.TimeUnit.SECONDS 7 | 8 | 9 | buildscript { 10 | repositories { 11 | mavenCentral() 12 | } 13 | } 14 | 15 | plugins { 16 | java 17 | kotlin("jvm") version "2.1.10" 18 | idea 19 | id("com.github.johnrengelman.shadow") version "8.1.1" 20 | id("org.jmailen.kotlinter") version "5.0.1" 21 | } 22 | 23 | 24 | group = "com.github.mduesterhoeft" 25 | version = "1.0-SNAPSHOT" 26 | 27 | repositories { 28 | mavenCentral() 29 | maven { url = URI("https://jitpack.io") } 30 | } 31 | 32 | dependencies { 33 | implementation(kotlin("stdlib")) 34 | implementation(kotlin("reflect")) 35 | 36 | implementation("io.moia.lambda-kotlin-request-router:router:1.1.0") 37 | 38 | implementation("com.amazonaws:aws-lambda-java-core:1.2.3") 39 | implementation("com.amazonaws:aws-lambda-java-log4j2:1.6.0") 40 | implementation("com.fasterxml.jackson.core:jackson-databind:2.18.3") 41 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.3") 42 | implementation("com.google.guava:guava:23.0") 43 | 44 | implementation("ch.qos.logback:logback-classic:1.5.17") 45 | implementation("org.slf4j:log4j-over-slf4j:2.0.17") 46 | 47 | testImplementation("org.junit.jupiter:junit-jupiter-engine:5.12.0") 48 | } 49 | 50 | tasks { 51 | withType { 52 | kotlinOptions.jvmTarget = "17" 53 | } 54 | 55 | withType { 56 | useJUnitPlatform() 57 | } 58 | 59 | withType { 60 | archiveBaseName.set(project.name) 61 | archiveClassifier.set("") 62 | archiveVersion.set("") 63 | transform(Log4j2PluginsCacheFileTransformer::class.java) 64 | } 65 | 66 | val deploy by creating(Exec::class) { 67 | dependsOn("test", "shadowJar") 68 | commandLine("serverless", "deploy") 69 | } 70 | } 71 | 72 | configurations.all { 73 | resolutionStrategy.cacheChangingModulesFor(0, SECONDS) 74 | } -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moia-oss/lambda-kotlin-request-router/d9793e80364cd4df43069e301f05d4c1fa04adb2/samples/lambda-kotlin-request-router-sample/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample/gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample/serverless.yml: -------------------------------------------------------------------------------- 1 | service: request-router-sample 2 | 3 | provider: 4 | name: aws 5 | runtime: java8 6 | region: eu-west-1 7 | 8 | package: 9 | artifact: build/libs/lambda-kotlin-request-router-sample.jar 10 | 11 | functions: 12 | my-handler: 13 | handler: io.moia.router.sample.MyRequestHandler 14 | events: 15 | - http: 16 | path: some 17 | method: get 18 | - http: 19 | path: some 20 | method: post 21 | -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'lambda-kotlin-request-router-sample' 2 | -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample/src/main/kotlin/io/moia/router/sample/MyRequestHandler.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.sample 2 | 3 | import io.moia.router.Filter 4 | import io.moia.router.Request 5 | import io.moia.router.RequestHandler 6 | import io.moia.router.ResponseEntity 7 | import io.moia.router.Router.Companion.router 8 | import io.moia.router.then 9 | import org.slf4j.Logger 10 | import org.slf4j.LoggerFactory 11 | import org.slf4j.MDC 12 | 13 | class MyRequestHandler : RequestHandler() { 14 | private val controller = SomeController() 15 | 16 | override val router = router { 17 | // use filters to add cross-cutting concerns to each request 18 | filter = loggingFilter().then(mdcFilter()) 19 | 20 | // functions can be externalized... 21 | GET("/some", controller::get) 22 | 23 | // simple handlers can also be declared inline 24 | POST("/some") { r: Request -> ResponseEntity.ok(r.body) } 25 | } 26 | 27 | private fun loggingFilter() = Filter { next -> { 28 | request -> 29 | log.info("Handling request ${request.httpMethod} ${request.path}") 30 | next(request) } 31 | } 32 | 33 | private fun mdcFilter() = Filter { next -> { 34 | request -> 35 | MDC.put("requestId", request.requestContext?.requestId) 36 | next(request) } 37 | } 38 | 39 | companion object { 40 | val log: Logger = LoggerFactory.getLogger(MyRequestHandler::class.java) 41 | } 42 | } -------------------------------------------------------------------------------- /samples/lambda-kotlin-request-router-sample/src/main/kotlin/io/moia/router/sample/SomeController.kt: -------------------------------------------------------------------------------- 1 | package io.moia.router.sample 2 | 3 | import io.moia.router.Request 4 | import io.moia.router.ResponseEntity 5 | 6 | class SomeController { 7 | 8 | fun get(request: Request) = 9 | ResponseEntity.ok(Sample("hello")) 10 | } 11 | 12 | data class Sample(val hello: String) -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'lambda-kotlin-request-router' 2 | include 'router' 3 | include 'router-protobuf' 4 | include 'router-openapi-request-validator' --------------------------------------------------------------------------------