├── archetype ├── src │ ├── test │ │ └── resources │ │ │ └── projects │ │ │ └── basic │ │ │ ├── goal.txt │ │ │ └── archetype.properties │ └── main │ │ └── resources │ │ ├── archetype-resources │ │ ├── local-server │ │ │ ├── src │ │ │ │ └── main │ │ │ │ │ ├── resources │ │ │ │ │ └── log4j2.xml │ │ │ │ │ └── kotlin │ │ │ │ │ └── localserver │ │ │ │ │ └── Main.kt │ │ │ └── pom.xml │ │ ├── core │ │ │ ├── src │ │ │ │ ├── main │ │ │ │ │ ├── resources │ │ │ │ │ │ └── log4j2.xml │ │ │ │ │ ├── kotlin │ │ │ │ │ │ └── core │ │ │ │ │ │ │ ├── Config.kt │ │ │ │ │ │ │ ├── generated │ │ │ │ │ │ │ └── Generated.kt │ │ │ │ │ │ │ └── ApiDefinition.kt │ │ │ │ │ └── cloudformation │ │ │ │ │ │ └── root.template │ │ │ │ └── assembly │ │ │ │ │ └── dist.xml │ │ │ └── pom.xml │ │ └── pom.xml │ │ └── META-INF │ │ └── maven │ │ └── archetype-metadata.xml └── pom.xml ├── .travis.yml ├── gradle-plugin ├── settings.gradle ├── src │ └── main │ │ ├── resources │ │ ├── META-INF │ │ │ └── gradle-plugins │ │ │ │ ├── ws.osiris.deploy.properties │ │ │ │ └── ws.osiris.project.properties │ │ └── archetype-resources │ │ │ ├── settings.gradle │ │ │ ├── local-server │ │ │ └── build.gradle │ │ │ ├── build.gradle │ │ │ └── core │ │ │ └── build.gradle │ │ ├── gradle │ │ └── generateProject.gradle │ │ └── kotlin │ │ └── ws │ │ └── osiris │ │ └── gradle │ │ ├── ProjectPlugin.kt │ │ └── DeployPlugin.kt └── build.gradle ├── core ├── src │ ├── test │ │ ├── static │ │ │ ├── foo │ │ │ │ └── bar.html │ │ │ └── index.html │ │ └── kotlin │ │ │ └── ws │ │ │ └── osiris │ │ │ └── core │ │ │ ├── ContentTypeTest.kt │ │ │ ├── StandardFilterTest.kt │ │ │ ├── InMemoryTestClientTest.kt │ │ │ ├── HttpTest.kt │ │ │ ├── MatchTest.kt │ │ │ ├── TestClient.kt │ │ │ └── ModelTest.kt │ └── main │ │ └── kotlin │ │ └── ws │ │ └── osiris │ │ └── core │ │ ├── Route.kt │ │ └── Filters.kt └── pom.xml ├── integration ├── src │ ├── test │ │ ├── e2e-test │ │ │ ├── static │ │ │ │ ├── baz │ │ │ │ │ └── bar.html │ │ │ │ └── index.html │ │ │ └── code │ │ │ │ └── ApiDefinition.kt │ │ ├── static │ │ │ ├── baz │ │ │ │ └── bar.html │ │ │ └── index.html │ │ ├── kotlin │ │ │ └── ws │ │ │ │ └── osiris │ │ │ │ └── integration │ │ │ │ ├── Main.kt │ │ │ │ ├── TestHelpersTest.kt │ │ │ │ └── IntegrationTests.kt │ │ └── resources │ │ │ └── log4j2.xml │ └── main │ │ └── kotlin │ │ └── ws │ │ └── osiris │ │ └── integration │ │ └── IntegrationTestApi.kt └── pom.xml ├── local-server ├── src │ └── test │ │ ├── static │ │ ├── foo │ │ │ └── bar.html │ │ └── index.html │ │ └── kotlin │ │ └── ws │ │ └── osiris │ │ └── localserver │ │ ├── LocalHttpTestClient.kt │ │ └── LocalServerTest.kt └── pom.xml ├── .gitignore ├── RELEASE.md ├── README.md ├── aws ├── src │ ├── main │ │ └── kotlin │ │ │ └── ws │ │ │ └── osiris │ │ │ └── aws │ │ │ ├── Request.kt │ │ │ ├── KeepAlive.kt │ │ │ └── Lambda.kt │ └── test │ │ └── kotlin │ │ └── ws │ │ └── osiris │ │ └── aws │ │ ├── ConfigTest.kt │ │ └── RequestTest.kt └── pom.xml ├── .github ├── workflows │ └── maven.yml └── dependabot.yml ├── aws-deploy ├── src │ ├── test │ │ └── kotlin │ │ │ └── ws │ │ │ └── osiris │ │ │ ├── awsdeploy │ │ │ ├── DeployTest.kt │ │ │ └── cloudformation │ │ │ │ └── ResourceNodeTest.kt │ │ │ └── aws │ │ │ └── cloudformation │ │ │ └── TemplateTest.kt │ └── main │ │ └── kotlin │ │ └── ws │ │ └── osiris │ │ └── awsdeploy │ │ ├── cloudformation │ │ └── ResourceTree.kt │ │ ├── Profile.kt │ │ └── Deploy.kt └── pom.xml ├── maven-plugin ├── pom.xml └── src │ └── main │ └── kotlin │ └── ws │ └── osiris │ └── maven │ └── Maven.kt ├── server ├── src │ └── test │ │ └── kotlin │ │ └── ws │ │ └── osiris │ │ └── server │ │ └── HttpTestClient.kt └── pom.xml ├── bom └── pom.xml └── .idea └── codeStyleSettings.xml /archetype/src/test/resources/projects/basic/goal.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | jdk: 3 | - openjdk8 4 | -------------------------------------------------------------------------------- /gradle-plugin/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = "osiris-gradle-plugin" 2 | -------------------------------------------------------------------------------- /core/src/test/static/foo/bar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

hello, bar!

4 | 5 | 6 | -------------------------------------------------------------------------------- /core/src/test/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

hello, world!

4 | 5 | 6 | -------------------------------------------------------------------------------- /integration/src/test/e2e-test/static/baz/bar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

hello, bar!

4 | 5 | 6 | -------------------------------------------------------------------------------- /integration/src/test/e2e-test/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

hello, world!

4 | 5 | 6 | -------------------------------------------------------------------------------- /integration/src/test/static/baz/bar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

hello, bar!

4 | 5 | 6 | -------------------------------------------------------------------------------- /integration/src/test/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

hello, world!

4 | 5 | 6 | -------------------------------------------------------------------------------- /local-server/src/test/static/foo/bar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

hello, bar!

4 | 5 | 6 | -------------------------------------------------------------------------------- /local-server/src/test/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

hello, world!

4 | 5 | 6 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/resources/META-INF/gradle-plugins/ws.osiris.deploy.properties: -------------------------------------------------------------------------------- 1 | implementation-class=ws.osiris.gradle.OsirisDeployPlugin 2 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/resources/META-INF/gradle-plugins/ws.osiris.project.properties: -------------------------------------------------------------------------------- 1 | implementation-class=ws.osiris.gradle.OsirisProjectPlugin 2 | -------------------------------------------------------------------------------- /archetype/src/test/resources/projects/basic/archetype.properties: -------------------------------------------------------------------------------- 1 | #Sun May 28 11:08:44 BST 2017 2 | package=it.pkg 3 | version=0.1-SNAPSHOT 4 | groupId=archetype.it 5 | artifactId=basic 6 | osirisVersion=whatever 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | *.iml 3 | *.DS_Store 4 | */target/* 5 | !.idea/codeStyleSettings.xml 6 | gradle-plugin/gradle/wrapper 7 | gradle-plugin/gradlew 8 | gradle-plugin/gradlew.bat 9 | gradle-plugin/.gradle 10 | gradle-plugin/build 11 | gradle-plugin/out 12 | # Project exclude paths 13 | /target/ -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Main Project 2 | `gpg2` needs to be on the path. 3 | 4 | From the root directory: 5 | 6 | export GPG_TTY=$(tty) 7 | mvn clean install 8 | mvn deploy -Prelease 9 | 10 | # Gradle Plugin 11 | From the `gradle-plugin` directory 12 | 13 | ./gradlew clean publishPlugins 14 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/resources/archetype-resources/settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = '${rootArtifactId}' 2 | include ':${rootArtifactId}-core' 3 | include ':${rootArtifactId}-local-server' 4 | 5 | project(':${rootArtifactId}-core').projectDir = "$rootDir/core" as File 6 | project(':${rootArtifactId}-local-server').projectDir = "$rootDir/local-server" as File 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Osiris - Simple Serverless Web Apps 2 | 3 | Osiris is a Kotlin library that makes it easy to write and deploy serverless web applications on AWS. For more details please see the [website](http://www.osiris.ws/), the [wiki](https://github.com/cjkent/osiris/wiki/) or the [example projects](https://github.com/cjkent/osiris-examples). 4 | 5 | Osiris is used in production at [CodeScreen](https://www.codescreen.com/). 6 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/resources/archetype-resources/local-server/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation project(':${rootArtifactId}-core') 3 | implementation platform("ws.osiris:osiris-bom:$osirisVersion") 4 | implementation 'org.jetbrains.kotlin:kotlin-stdlib' 5 | implementation 'ws.osiris:osiris-local-server' 6 | implementation 'org.apache.logging.log4j:log4j-core' 7 | implementation 'org.apache.logging.log4j:log4j-slf4j2-impl' 8 | } 9 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/gradle/generateProject.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'ws.osiris.project' version '2.0.0' 3 | } 4 | // For local testing the plugins block needs to be replaced with 5 | // 6 | // buildscript { 7 | // repositories { 8 | // jcenter() 9 | // mavenLocal() 10 | // } 11 | // dependencies { 12 | // classpath 'ws.osiris:osiris-gradle-plugin:2.0.0' 13 | // } 14 | // } 15 | // apply plugin: 'ws.osiris.project' 16 | 17 | defaultTasks 'generateProject' 18 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/resources/archetype-resources/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'org.jetbrains.kotlin.jvm' version '1.3.31' apply false 3 | } 4 | 5 | subprojects { 6 | apply plugin: 'org.jetbrains.kotlin.jvm' 7 | 8 | repositories { 9 | jcenter() 10 | } 11 | compileKotlin { 12 | kotlinOptions { 13 | jvmTarget = '11' 14 | } 15 | } 16 | compileTestKotlin { 17 | kotlinOptions { 18 | jvmTarget = '11' 19 | } 20 | } 21 | } 22 | 23 | ext { 24 | osirisVersion = '2.0.0' 25 | } 26 | -------------------------------------------------------------------------------- /integration/src/test/kotlin/ws/osiris/integration/Main.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.integration 2 | 3 | import ws.osiris.server.HttpTestClient 4 | import ws.osiris.server.Protocol 5 | 6 | fun main(args: Array) { 7 | if (args.size < 3) throw IllegalArgumentException("Required arguments: API ID, region, stage") 8 | val apiId = args[0] 9 | val region = args[1] 10 | val stage = args[2] 11 | val client = HttpTestClient(Protocol.HTTPS, "$apiId.execute-api.$region.amazonaws.com", 443, "/$stage") 12 | assertApi(client) 13 | println("Everything passed") 14 | } 15 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/resources/archetype-resources/core/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'ws.osiris.deploy' version '2.0.0' 3 | } 4 | 5 | dependencies { 6 | implementation platform("ws.osiris:osiris-bom:$osirisVersion") 7 | implementation 'org.jetbrains.kotlin:kotlin-stdlib' 8 | implementation 'ws.osiris:osiris-core' 9 | implementation 'ws.osiris:osiris-aws' 10 | implementation 'com.google.code.gson:gson' 11 | implementation 'org.apache.logging.log4j:log4j-slf4j2-impl' 12 | implementation 'com.amazonaws:aws-lambda-java-log4j2' 13 | } 14 | 15 | osiris { 16 | rootPackage = '${package}' 17 | } 18 | -------------------------------------------------------------------------------- /integration/src/test/kotlin/ws/osiris/integration/TestHelpersTest.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.integration 2 | 3 | import org.testng.annotations.Test 4 | import java.nio.file.Files 5 | import java.nio.file.Paths 6 | import kotlin.test.assertTrue 7 | 8 | @Test 9 | class TestHelpersTest { 10 | 11 | fun copyDirectoryContents() { 12 | TmpDirResource().use { 13 | copyDirectoryContents(Paths.get("src/test/static"), it.path) 14 | assertTrue(Files.isRegularFile(it.path.resolve("baz/bar.html"))) 15 | assertTrue(Files.isRegularFile(it.path.resolve("index.html"))) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /integration/src/test/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{yyyy-MM-dd HH:mm:ss.SSS} %-5p %c - %m%n 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /archetype/src/main/resources/archetype-resources/local-server/src/main/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{yyyy-MM-dd HH:mm:ss.SSS} %-5p %c - %m%n 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /archetype/src/main/resources/archetype-resources/core/src/main/resources/log4j2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{yyyy-MM-dd HH:mm:ss.SSS} %X{AWSRequestId} %-5p %c - %m%n 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /archetype/src/main/resources/archetype-resources/core/src/assembly/dist.xml: -------------------------------------------------------------------------------- 1 | 4 | dist 5 | 6 | zip 7 | 8 | false 9 | 10 | 11 | /lib 12 | true 13 | false 14 | runtime 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /archetype/src/main/resources/archetype-resources/local-server/src/main/kotlin/localserver/Main.kt: -------------------------------------------------------------------------------- 1 | package ${package}.localserver 2 | 3 | import com.beust.jcommander.JCommander 4 | import ws.osiris.localserver.ServerArgs 5 | import ws.osiris.localserver.runLocalServer 6 | 7 | import ${package}.core.api 8 | import ${package}.core.createComponents 9 | 10 | /** 11 | * Main function for running the application in an HTTP server (Jetty). 12 | * 13 | * The arguments are: 14 | * * `-p`, `--port` - the port the server listens on. Defaults to 8080. 15 | * * `-r`, `--root` - the context root. This is the base path from which everything is served. 16 | */ 17 | fun main(args: Array) { 18 | val serverArgs = ServerArgs() 19 | JCommander.newBuilder().addObject(serverArgs).build().parse(*args) 20 | val components = createComponents() 21 | runLocalServer(api, components, serverArgs.port, serverArgs.root, "core/src/main/static") 22 | } 23 | -------------------------------------------------------------------------------- /aws/src/main/kotlin/ws/osiris/aws/Request.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.aws 2 | 3 | import com.amazonaws.services.lambda.runtime.Context 4 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 5 | import ws.osiris.core.Request 6 | 7 | /** The AWS stage variables; this is an extension property to avoid putting AWS concepts into the core module. */ 8 | @Suppress("UNCHECKED_CAST") 9 | val Request.stageVariables: Map 10 | get() = this.attributes[STAGE_VARS_ATTR] as? Map? ?: mapOf() 11 | 12 | /** The event passed to the lambda by AWS. */ 13 | val Request.lambdaEvent: APIGatewayProxyRequestEvent? 14 | // If this isn't available in the attributes it could be created from the context 15 | get() = this.attributes[LAMBDA_EVENT_ATTR] as? APIGatewayProxyRequestEvent 16 | 17 | /** The context passed to the lambda by AWS. */ 18 | val Request.lambdaContext: Context? 19 | get() = this.attributes[LAMBDA_CONTEXT_ATTR] as? Context 20 | 21 | /** The AWS stage name; this is an extension property to avoid putting AWS concepts into the core module. */ 22 | val Request.stageName: String get() = this.context.optional("stage") ?: "UNKNOWN" 23 | -------------------------------------------------------------------------------- /archetype/src/main/resources/archetype-resources/core/src/main/kotlin/core/Config.kt: -------------------------------------------------------------------------------- 1 | package ${package}.core 2 | 3 | import ws.osiris.aws.ApplicationConfig 4 | import ws.osiris.aws.Stage 5 | import java.time.Duration 6 | 7 | /** 8 | * Configuration that controls how the application is deployed to AWS. 9 | */ 10 | val config = ApplicationConfig( 11 | applicationName = "${rootArtifactId}", 12 | lambdaMemorySizeMb = 512, 13 | lambdaTimeout = Duration.ofSeconds(10), 14 | environmentVariables = mapOf( 15 | "EXAMPLE_ENVIRONMENT_VARIABLE" to "Bob" 16 | ), 17 | stages = listOf( 18 | Stage( 19 | name = "dev", 20 | description = "Development stage", 21 | deployOnUpdate = true, 22 | variables = mapOf( 23 | "VAR1" to "devValue1", 24 | "VAR2" to "devValue2" 25 | ) 26 | ), 27 | Stage( 28 | name = "prod", 29 | description = "Production stage", 30 | deployOnUpdate = false, 31 | variables = mapOf( 32 | "VAR1" to "prodValue1", 33 | "VAR2" to "prodValue2" 34 | ) 35 | ) 36 | ) 37 | ) 38 | -------------------------------------------------------------------------------- /archetype/src/main/resources/archetype-resources/core/src/main/kotlin/core/generated/Generated.kt: -------------------------------------------------------------------------------- 1 | package ${package}.core.generated 2 | 3 | import ws.osiris.aws.ApiFactory 4 | import ws.osiris.aws.ApplicationConfig 5 | import ws.osiris.aws.ProxyLambda 6 | import ws.osiris.core.Api 7 | import ws.osiris.core.ComponentsProvider 8 | 9 | import ${package}.core.api 10 | import ${package}.core.createComponents 11 | 12 | /** 13 | * The lambda function that is deployed to AWS and provides the entry point to the application. 14 | * 15 | * This is generated code and should not be modified or deleted. 16 | */ 17 | @Suppress("UNCHECKED_CAST", "unused") 18 | class GeneratedLambda : ProxyLambda(api as Api, createComponents()) 19 | 20 | /** 21 | * Creates the API and application configuration; used by the build plugins during deployment. 22 | * 23 | * This is generated code and should not be modified or deleted. 24 | */ 25 | @Suppress("UNCHECKED_CAST", "unused") 26 | class GeneratedApiFactory : ApiFactory { 27 | override val api: Api = ${package}.core.api as Api 28 | override val config: ApplicationConfig = ${package}.core.config 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Java CI with Maven 10 | 11 | on: 12 | push: 13 | branches: [ "main" ] 14 | pull_request: 15 | branches: [ "main" ] 16 | 17 | jobs: 18 | build: 19 | 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up JDK 17 25 | uses: actions/setup-java@v3 26 | with: 27 | java-version: '17' 28 | distribution: 'temurin' 29 | cache: maven 30 | - name: Build with Maven 31 | run: mvn -B package --file pom.xml 32 | 33 | # Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive 34 | # - name: Update dependency graph 35 | # uses: advanced-security/maven-dependency-submission-action@571e99aab1055c2e71a1e2309b9691de18d6b7d6 36 | -------------------------------------------------------------------------------- /aws-deploy/src/test/kotlin/ws/osiris/awsdeploy/DeployTest.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.awsdeploy 2 | 3 | import org.intellij.lang.annotations.Language 4 | import org.testng.annotations.Test 5 | import kotlin.test.assertEquals 6 | 7 | @Test 8 | class DeployTest { 9 | 10 | fun generatedTemplateParameters() { 11 | val templateUrl = "https://test-app-code.s3.eu-west-1.amazonaws.com/test-app.template" 12 | @Language("yaml") 13 | val template = """ 14 | Resources: 15 | Foo: 16 | Type: AWS::Whatever 17 | Properties: 18 | Bar: baz 19 | Bar: 20 | Type: AWS::CloudFormation::Stack 21 | Properties: 22 | TemplateURL: "https://some_bucket.s3.eu-west-1.amazonaws.com/whatever.template" 23 | Parameters: 24 | FooParam: fooValue 25 | BarParam: barValue 26 | ApiStack: 27 | Type: AWS::CloudFormation::Stack 28 | Properties: 29 | TemplateURL: "$templateUrl" 30 | Parameters: 31 | Param1: value1 32 | Param2: value2 33 | """.trimIndent() 34 | val parameters = generatedTemplateParameters(template, "test-app") 35 | assertEquals(setOf("Param1", "Param2"), parameters) 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /local-server/src/test/kotlin/ws/osiris/localserver/LocalHttpTestClient.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.localserver 2 | 3 | import org.eclipse.jetty.server.Server 4 | import ws.osiris.core.Api 5 | import ws.osiris.core.ComponentsProvider 6 | import ws.osiris.core.RequestContextFactory 7 | import ws.osiris.core.TestClient 8 | import ws.osiris.server.HttpTestClient 9 | import ws.osiris.server.Protocol 10 | 11 | /** 12 | * Test client that executes HTTP requests against an API hosted by an in-process Jetty server. 13 | */ 14 | class LocalHttpTestClient private constructor( 15 | private val httpClient: TestClient, 16 | private val server: Server 17 | ) : TestClient by httpClient, AutoCloseable { 18 | 19 | override fun close() { 20 | server.stop() 21 | } 22 | 23 | companion object { 24 | 25 | // TODO randomise the port, retry a few times if the server doesn't start 26 | 27 | /** Returns a client for an API that uses components in its handlers. */ 28 | fun create( 29 | api: Api, 30 | components: T, 31 | staticFilesDir: String? = null, 32 | requestContextFactory: RequestContextFactory = RequestContextFactory.empty() 33 | ): LocalHttpTestClient { 34 | 35 | val port = 8080 36 | val server = createLocalServer( 37 | api, 38 | components, 39 | staticFilesDir = staticFilesDir, 40 | requestContextFactory = requestContextFactory 41 | ) 42 | val client = HttpTestClient(Protocol.HTTP, "localhost", port) 43 | server.start() 44 | return LocalHttpTestClient(client, server) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /aws-deploy/src/main/kotlin/ws/osiris/awsdeploy/cloudformation/ResourceTree.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.awsdeploy.cloudformation 2 | 3 | internal fun ResourceTemplate.partitionChildren( 4 | firstPartitionMaxSize: Int, 5 | partitionMaxSize: Int 6 | ): List> { 7 | 8 | val firstPartition = partition(firstPartitionMaxSize, children) 9 | val resources = mutableListOf>() 10 | var partitionedCount = firstPartition.size 11 | var remaining = children.subList(partitionedCount, children.size) 12 | resources.add(firstPartition) 13 | while (partitionedCount < children.size) { 14 | val partition = partition(partitionMaxSize, remaining) 15 | partitionedCount += partition.size 16 | resources.add(partition) 17 | remaining = children.subList(partitionedCount, children.size) 18 | } 19 | return resources 20 | } 21 | 22 | private fun partition(maxSize: Int, nodes: List): List { 23 | // This happens if there is nothing but the root node 24 | if (nodes.isEmpty()) return listOf() 25 | // TODO is there a sane way to do this without all the mutation? or maybe with just mutation of the list? 26 | var curCount = 0 27 | var idx = 0 28 | val resources = mutableListOf() 29 | while (idx < nodes.size) { 30 | val node = nodes[idx] 31 | curCount += node.resourceCount 32 | if (curCount <= maxSize) { 33 | resources.add(node) 34 | idx++ 35 | } else { 36 | break 37 | } 38 | } 39 | if (resources.isEmpty()) { 40 | throw IllegalStateException("Failed to partition resources") 41 | } 42 | return resources 43 | } 44 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: maven 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: com.amazonaws:aws-java-sdk-bom 10 | versions: 11 | - 1.11.1000 12 | - 1.11.1001 13 | - 1.11.1002 14 | - 1.11.1003 15 | - 1.11.1004 16 | - 1.11.1005 17 | - 1.11.1006 18 | - 1.11.945 19 | - 1.11.946 20 | - 1.11.947 21 | - 1.11.948 22 | - 1.11.949 23 | - 1.11.950 24 | - 1.11.951 25 | - 1.11.952 26 | - 1.11.953 27 | - 1.11.954 28 | - 1.11.955 29 | - 1.11.956 30 | - 1.11.957 31 | - 1.11.958 32 | - 1.11.959 33 | - 1.11.960 34 | - 1.11.961 35 | - 1.11.962 36 | - 1.11.963 37 | - 1.11.964 38 | - 1.11.965 39 | - 1.11.966 40 | - 1.11.967 41 | - 1.11.968 42 | - 1.11.969 43 | - 1.11.970 44 | - 1.11.971 45 | - 1.11.972 46 | - 1.11.973 47 | - 1.11.974 48 | - 1.11.975 49 | - 1.11.976 50 | - 1.11.977 51 | - 1.11.978 52 | - 1.11.979 53 | - 1.11.980 54 | - 1.11.981 55 | - 1.11.982 56 | - 1.11.983 57 | - 1.11.986 58 | - 1.11.987 59 | - 1.11.988 60 | - 1.11.990 61 | - 1.11.991 62 | - 1.11.992 63 | - 1.11.993 64 | - 1.11.994 65 | - 1.11.995 66 | - 1.11.996 67 | - 1.11.997 68 | - 1.11.998 69 | - 1.11.999 70 | - dependency-name: org.jetbrains.dokka:dokka-maven-plugin 71 | versions: 72 | - 1.4.20-dev-65 73 | - 1.4.30 74 | - dependency-name: org.jetbrains.kotlin:kotlin-maven-plugin 75 | versions: 76 | - 1.4.21-2 77 | - dependency-name: org.jetbrains.kotlin:kotlin-test 78 | versions: 79 | - 1.4.21-2 80 | - dependency-name: org.jetbrains.kotlin:kotlin-reflect 81 | versions: 82 | - 1.4.21-2 83 | - dependency-name: org.jetbrains.kotlin:kotlin-stdlib 84 | versions: 85 | - 1.4.21-2 86 | - dependency-name: com.beust:jcommander 87 | versions: 88 | - "1.80" 89 | - dependency-name: org.apache.logging.log4j:log4j-slf4j2-impl 90 | versions: 91 | - 2.14.0 92 | - dependency-name: com.amazonaws:aws-lambda-java-events 93 | versions: 94 | - 3.7.0 95 | -------------------------------------------------------------------------------- /maven-plugin/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | 7 | ws.osiris 8 | osiris 9 | 2.0.1-SNAPSHOT 10 | .. 11 | 12 | 13 | osiris-maven-plugin 14 | maven-plugin 15 | 16 | Osiris Maven Plugin 17 | Maven plugin for deploying to AWS 18 | 19 | 20 | 21 | ws.osiris 22 | osiris-aws-deploy 23 | 24 | 25 | org.jetbrains.kotlin 26 | kotlin-stdlib 27 | 28 | 29 | 30 | com.googlecode.slf4j-maven-plugin-log 31 | slf4j-maven-plugin-log 32 | 1.0.0 33 | 34 | 35 | 36 | 37 | org.apache.maven 38 | maven-plugin-api 39 | 3.9.6 40 | provided 41 | 42 | 43 | org.apache.maven.plugin-tools 44 | maven-plugin-annotations 45 | 3.11.0 46 | provided 47 | 48 | 49 | org.apache.maven 50 | maven-project 51 | 2.2.1 52 | provided 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /aws/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | 7 | ws.osiris 8 | osiris 9 | 2.0.1-SNAPSHOT 10 | .. 11 | 12 | osiris-aws 13 | jar 14 | Osiris AWS 15 | Utilities for deploying to AWS 16 | 17 | 18 | 19 | ws.osiris 20 | osiris-core 21 | 22 | 23 | ws.osiris 24 | osiris-server 25 | 26 | 27 | org.jetbrains.kotlin 28 | kotlin-stdlib 29 | 30 | 31 | org.slf4j 32 | slf4j-api 33 | 34 | 35 | com.amazonaws 36 | aws-java-sdk-lambda 37 | 38 | 39 | com.amazonaws 40 | aws-lambda-java-core 41 | 42 | 43 | com.amazonaws 44 | aws-lambda-java-events 45 | 46 | 47 | 48 | 49 | org.testng 50 | testng 51 | test 52 | 53 | 54 | org.jetbrains.kotlin 55 | kotlin-test 56 | test 57 | 58 | 59 | com.fasterxml.jackson.core 60 | jackson-databind 61 | test 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /archetype/src/main/resources/archetype-resources/core/src/main/kotlin/core/ApiDefinition.kt: -------------------------------------------------------------------------------- 1 | package ${package}.core 2 | 3 | import ws.osiris.core.MimeTypes 4 | import ws.osiris.core.ComponentsProvider 5 | import ws.osiris.core.HttpHeaders 6 | import ws.osiris.core.api 7 | 8 | /** 9 | * The name of an environment variable used to pass in configuration. 10 | * 11 | * The values of environment variables can be specified by the `environmentVariables` property in the configuration: 12 | * 13 | * val config = ApplicationConfig( 14 | * environmentVariables = mapOf( 15 | * "EXAMPLE_ENVIRONMENT_VARIABLE" to "Bob" 16 | * ) 17 | * ... 18 | * ) 19 | */ 20 | const val EXAMPLE_ENVIRONMENT_VARIABLE = "EXAMPLE_ENVIRONMENT_VARIABLE" 21 | 22 | /** The API. */ 23 | val api = api { 24 | 25 | get("/helloworld") { 26 | // return a map that is automatically converted to JSON 27 | mapOf("message" to "hello, world!") 28 | } 29 | 30 | get("/helloplain") { req -> 31 | // return a response with customised headers 32 | req.responseBuilder() 33 | .header(HttpHeaders.CONTENT_TYPE, MimeTypes.TEXT_PLAIN) 34 | .build("hello, world!") 35 | } 36 | 37 | get("/helloqueryparam") { req -> 38 | // get a query parameter 39 | val name = req.queryParams["name"] 40 | mapOf("message" to "hello, $name!") 41 | } 42 | 43 | get("/helloenv") { 44 | // use the name property from ExampleComponents for the name 45 | mapOf("message" to "hello, $name!") 46 | } 47 | 48 | get("/hello/{name}") { req -> 49 | // get a path parameter 50 | val name = req.pathParams["name"] 51 | mapOf("message" to "hello, $name!") 52 | } 53 | } 54 | 55 | /** 56 | * Creates the components used by the example API. 57 | */ 58 | fun createComponents(): ExampleComponents = ExampleComponentsImpl() 59 | 60 | /** 61 | * A trivial set of components that exposes a simple property to the request handling code in the API definition. 62 | */ 63 | interface ExampleComponents : ComponentsProvider { 64 | val name: String 65 | } 66 | 67 | /** 68 | * An implementation of `ExampleComponents` that uses an environment variable to populate its data. 69 | */ 70 | class ExampleComponentsImpl : ExampleComponents { 71 | override val name: String = System.getenv(EXAMPLE_ENVIRONMENT_VARIABLE) ?: 72 | "[Environment variable EXAMPLE_ENVIRONMENT_VARIABLE not set]" 73 | } 74 | -------------------------------------------------------------------------------- /core/src/test/kotlin/ws/osiris/core/ContentTypeTest.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.core 2 | 3 | import org.testng.annotations.Test 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertNull 6 | 7 | @Test 8 | class ContentTypeTest { 9 | 10 | fun parseSimple() { 11 | val (mimeType, charset) = ContentType.parse("text/plain; charset=UTF-8") 12 | assertEquals("text/plain", mimeType) 13 | assertEquals(Charsets.UTF_8, charset) 14 | } 15 | 16 | fun parseWithWhitespace() { 17 | val (mimeType, charset) = ContentType.parse(" text/plain ; charset=UTF-8 ") 18 | assertEquals("text/plain", mimeType) 19 | assertEquals(Charsets.UTF_8, charset) 20 | } 21 | 22 | fun parseNoWhitespace() { 23 | val (mimeType, charset) = ContentType.parse("text/plain;charset=UTF-8") 24 | assertEquals("text/plain", mimeType) 25 | assertEquals(Charsets.UTF_8, charset) 26 | } 27 | 28 | fun parseUppercaseCharset() { 29 | val (mimeType, charset) = ContentType.parse("text/plain; CHARSET=UTF-8") 30 | assertEquals("text/plain", mimeType) 31 | assertEquals(Charsets.UTF_8, charset) 32 | } 33 | 34 | fun parseNoCharset() { 35 | val (mimeType, charset) = ContentType.parse("text/plain") 36 | assertEquals("text/plain", mimeType) 37 | assertNull(charset) 38 | } 39 | 40 | fun header() { 41 | assertEquals("text/plain; charset=UTF-8", ContentType("text/plain", Charsets.UTF_8).header) 42 | } 43 | 44 | fun headerNoCharset() { 45 | assertEquals("text/plain", ContentType("text/plain").header) 46 | assertEquals("text/plain", ContentType(" text/plain ").header) 47 | } 48 | 49 | fun multipartForm() { 50 | val contentType = ContentType.parse("multipart/form-data;boundary=abc123") 51 | assertEquals(contentType.mimeType, "multipart/form-data") 52 | assertEquals(contentType.header, "multipart/form-data; boundary=abc123") 53 | assertEquals(contentType.boundary, "abc123") 54 | assertNull(contentType.charset) 55 | } 56 | 57 | fun multipartFormWithWhitespace() { 58 | val contentType = ContentType.parse(" multipart/form-data ; boundary=abc123 ") 59 | assertEquals(contentType.mimeType, "multipart/form-data") 60 | assertEquals(contentType.header, "multipart/form-data; boundary=abc123") 61 | assertEquals(contentType.boundary, "abc123") 62 | assertNull(contentType.charset) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /gradle-plugin/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.gradle.plugin-publish' version '0.11.0' 3 | id 'org.jetbrains.kotlin.jvm' version '1.3.31' 4 | // For publishing to local maven repo during development 5 | id 'maven-publish' 6 | } 7 | 8 | apply plugin: 'maven' 9 | 10 | group = 'ws.osiris' 11 | version = '2.0.0' 12 | 13 | ext { 14 | kotlinVersion = '1.9.22' 15 | osirisVersion = '2.0.0' 16 | } 17 | 18 | repositories { 19 | mavenLocal() 20 | jcenter() 21 | } 22 | 23 | dependencies { 24 | implementation gradleApi() 25 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" 26 | implementation "ws.osiris:osiris-archetype:$osirisVersion" 27 | implementation "ws.osiris:osiris-aws:$osirisVersion" 28 | implementation "ws.osiris:osiris-aws-deploy:$osirisVersion" 29 | } 30 | 31 | compileKotlin { 32 | kotlinOptions { 33 | jvmTarget = '11' 34 | } 35 | } 36 | 37 | compileTestKotlin { 38 | kotlinOptions { 39 | jvmTarget = '11' 40 | } 41 | } 42 | 43 | pluginBundle { 44 | website = 'http://www.osiris.ws/' 45 | vcsUrl = 'https://github.com/cjkent/osiris' 46 | description = 'Plugins for the Osiris Project' 47 | tags = ['osiris', 'aws', 'serverless', 'apigateway', 'lambda', 'web'] 48 | 49 | plugins { 50 | projectPlugin { 51 | id = 'ws.osiris.project' 52 | displayName = 'Osiris Project Plugin' 53 | description = 'Generates a project for building an application with Osiris' 54 | } 55 | deployPlugin { 56 | id = 'ws.osiris.deploy' 57 | displayName = 'Osiris Deploy Plugin' 58 | description = 'Builds the artifacts for an Osiris application, generates the configuration and deploys to AWS' 59 | } 60 | } 61 | } 62 | 63 | // Publishes the plugin to the local Maven repo during development 64 | // Use: gradlew publishToMavenLocal 65 | // Consuming projects will need to apply plugins using the old style buildscript syntax (core module): 66 | // 67 | // buildscript { 68 | // repositories { 69 | // jcenter() 70 | // mavenLocal() 71 | // } 72 | // dependencies { 73 | // classpath 'ws.osiris:osiris-gradle-plugin:2.0.0' 74 | // } 75 | // } 76 | // 77 | // apply plugin: 'ws.osiris.deploy' 78 | // 79 | // The root build.gradle in the consuming project will need mavenLocal() adding to the repositories block 80 | 81 | publishing { 82 | publications { 83 | maven(MavenPublication) { 84 | groupId = 'ws.osiris' 85 | artifactId = 'osiris-gradle-plugin' 86 | version = '2.0.0' 87 | from components.java 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /core/src/main/kotlin/ws/osiris/core/Route.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.core 2 | 3 | /** 4 | * A route describes one endpoint in a REST API. 5 | * 6 | * A route contains 7 | * 8 | * * The HTTP method it accepts, for example GET or POST 9 | * * The path to the endpoint, for example `/foo/bar` 10 | * * The authorisation needed to invoke the endpoint 11 | */ 12 | sealed class Route { 13 | 14 | abstract val path: String 15 | abstract val auth: Auth 16 | 17 | companion object { 18 | 19 | // TODO read the RFC in case there are any I've missed 20 | internal fun validatePath(path: String) { 21 | if (!pathPattern.matcher(path).matches()) throw IllegalArgumentException("Illegal path $path") 22 | } 23 | } 24 | } 25 | 26 | /** 27 | * Describes an endpoint in a REST API whose requests are handled by a lambda. 28 | * 29 | * It contains 30 | * 31 | * * The HTTP method it accepts, for example GET or POST 32 | * * The path to the endpoint, for example `/foo/bar` 33 | * * The code that is run when the endpoint receives a request (the "handler") 34 | * * The authorisation needed to invoke the endpoint 35 | */ 36 | data class LambdaRoute( 37 | val method: HttpMethod, 38 | override val path: String, 39 | val handler: RequestHandler, 40 | override val auth: Auth = NoAuth, 41 | val cors: Boolean = false 42 | ): Route() { 43 | 44 | init { 45 | validatePath(path) 46 | } 47 | 48 | internal fun wrap(filters: List>): LambdaRoute { 49 | val chain = filters.reversed().fold(handler, { requestHandler, filter -> wrapFilter(requestHandler, filter) }) 50 | return copy(handler = chain) 51 | } 52 | 53 | private fun wrapFilter(handler: RequestHandler, filter: Filter): RequestHandler { 54 | return { req -> 55 | val returnVal = when { 56 | filter.matches(req) -> filter.handler(this, req, handler) 57 | else -> handler(this, req) 58 | } 59 | returnVal as? Response ?: req.responseBuilder().build(returnVal) 60 | } 61 | } 62 | } 63 | 64 | /** 65 | * Describes an endpoint in a REST API that serves static files. 66 | * 67 | * It contains 68 | * 69 | * * The path to the endpoint, for example `/foo/bar` 70 | * * The authorisation needed to invoke the endpoint 71 | * * The name of the index file that will be served if no file is included in the URL 72 | */ 73 | data class StaticRoute( 74 | override val path: String, 75 | val indexFile: String?, 76 | override val auth: Auth = NoAuth 77 | ) : Route() { 78 | 79 | init { 80 | validatePath(path) 81 | // TODO validate the method and index file 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /server/src/test/kotlin/ws/osiris/server/HttpTestClient.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.server 2 | 3 | import okhttp3.MediaType.Companion.toMediaType 4 | import okhttp3.OkHttpClient 5 | import okhttp3.Request 6 | import okhttp3.RequestBody.Companion.toRequestBody 7 | import org.slf4j.LoggerFactory 8 | import ws.osiris.core.Headers 9 | import ws.osiris.core.HttpHeaders 10 | import ws.osiris.core.TestClient 11 | import ws.osiris.core.TestResponse 12 | 13 | typealias OkHttpResponse = okhttp3.Response 14 | 15 | /** 16 | * The protocol used when making a request. 17 | */ 18 | enum class Protocol(val protocolName: String, val defaultPort: Int) { 19 | HTTP("http", 80), 20 | HTTPS("https", 443) 21 | } 22 | 23 | /** 24 | * A very simple implementation of [TestClient] that makes HTTP or HTTPS requests. 25 | */ 26 | class HttpTestClient( 27 | private val protocol: Protocol, 28 | private val server: String, 29 | private val port: Int = protocol.defaultPort, 30 | private val basePath: String = "" 31 | ) : TestClient { 32 | 33 | private val client = OkHttpClient() 34 | 35 | override fun get(path: String, headers: Map): TestResponse { 36 | val request = Request.Builder().url(buildPath(path)).build() 37 | log.debug("Making request {}", request) 38 | val response = client.newCall(request).execute() 39 | val testResponse = TestResponse(response.code, response.headerMap(), response.body?.string()) 40 | log.debug("Received response {}", testResponse) 41 | return testResponse 42 | } 43 | 44 | override fun post(path: String, body: String, headers: Map): TestResponse { 45 | val mediaType = headers[HttpHeaders.CONTENT_TYPE]?.toMediaType() 46 | val requestBody = body.toRequestBody(mediaType) 47 | val request = Request.Builder().url(buildPath(path)).post(requestBody).build() 48 | log.debug("Making request {}", request) 49 | val response = client.newCall(request).execute() 50 | val testResponse = TestResponse(response.code, response.headerMap(), response.body?.string()) 51 | log.debug("Received response {}", testResponse) 52 | return testResponse 53 | } 54 | 55 | override fun options(path: String, headers: Map): TestResponse { 56 | val request = Request.Builder().url(buildPath(path)).method("OPTIONS", null).build() 57 | log.debug("Making request {}", request) 58 | val response = client.newCall(request).execute() 59 | val testResponse = TestResponse(response.code, response.headerMap(), null) 60 | log.debug("Received response {}", testResponse) 61 | return testResponse 62 | } 63 | 64 | private fun buildPath(path: String): String = "${protocol.protocolName}://$server:$port$basePath$path" 65 | 66 | private fun OkHttpResponse.headerMap(): Headers = 67 | Headers(this.headers.toMultimap().mapValues { (_, list) -> list[0] }) 68 | 69 | companion object { 70 | private val log = LoggerFactory.getLogger(HttpTestClient::class.java) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /archetype/src/main/resources/META-INF/maven/archetype-metadata.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 1.0.0-SNAPSHOT 12 | 13 | 14 | ${project.version} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | src/main/kotlin 23 | 24 | **/*.kt 25 | 26 | 27 | 28 | src/main/cloudformation 29 | 30 | **/*.* 31 | 32 | 33 | 34 | src/main/static 35 | 36 | 37 | src/assembly 38 | 39 | **/*.xml 40 | 41 | 42 | 43 | src/main/resources 44 | 45 | **/*.xml 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | src/main/kotlin 54 | 55 | **/*.kt 56 | 57 | 58 | 59 | src/main/resources 60 | 61 | **/*.xml 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /archetype/src/main/resources/archetype-resources/local-server/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | 7 | ${groupId} 8 | ${rootArtifactId} 9 | ${version} 10 | .. 11 | 12 | ${artifactId} 13 | jar 14 | 15 | 16 | 17 | org.jetbrains.kotlin 18 | kotlin-stdlib 19 | 20 | 21 | ${groupId} 22 | ${rootArtifactId}-core 23 | 24 | 25 | com.amazonaws 26 | aws-lambda-java-log4j2 27 | 28 | 29 | 30 | 31 | ws.osiris 32 | osiris-local-server 33 | 34 | 35 | org.apache.logging.log4j 36 | log4j-core 37 | 38 | 39 | org.apache.logging.log4j 40 | log4j-slf4j2-impl 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | org.codehaus.mojo 49 | exec-maven-plugin 50 | 1.5.0 51 | 52 | 53 | 54 | exec 55 | 56 | 57 | 58 | 59 | java 60 | 61 | -classpath 62 | 63 | ${package}.localserver.MainKt 64 | -p 65 | 8080 66 | 67 | 68 | Bob 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /aws-deploy/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | ws.osiris 6 | osiris 7 | 2.0.1-SNAPSHOT 8 | 9 | 10 | 4.0.0 11 | osiris-aws-deploy 12 | jar 13 | Osiris AWS Deploy 14 | 15 | 16 | 17 | ws.osiris 18 | osiris-core 19 | 20 | 21 | ws.osiris 22 | osiris-aws 23 | 24 | 25 | org.jetbrains.kotlin 26 | kotlin-stdlib 27 | 28 | 29 | org.slf4j 30 | slf4j-api 31 | 32 | 33 | 34 | 35 | com.amazonaws 36 | aws-java-sdk-lambda 37 | 38 | 39 | com.amazonaws 40 | aws-java-sdk-api-gateway 41 | 42 | 43 | com.amazonaws 44 | aws-java-sdk-sts 45 | 46 | 47 | com.amazonaws 48 | aws-java-sdk-iam 49 | 50 | 51 | com.amazonaws 52 | aws-java-sdk-s3 53 | 54 | 55 | com.amazonaws 56 | aws-java-sdk-cloudformation 57 | 58 | 59 | 60 | com.google.guava 61 | guava 62 | 63 | 64 | com.fasterxml.jackson.core 65 | jackson-databind 66 | 67 | 68 | com.fasterxml.jackson.dataformat 69 | jackson-dataformat-yaml 70 | 71 | 72 | 73 | 74 | org.jetbrains.kotlin 75 | kotlin-test 76 | 77 | 78 | org.testng 79 | testng 80 | test 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /aws/src/test/kotlin/ws/osiris/aws/ConfigTest.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.aws 2 | 3 | import org.testng.Assert.assertThrows 4 | import org.testng.annotations.Test 5 | 6 | @Test 7 | class ConfigTest { 8 | 9 | fun validateBucketNameValidName() { 10 | validateBucketName("foo-bar-baz") 11 | } 12 | 13 | fun validateBucketNameTooLong() { 14 | assertThrows(IllegalArgumentException::class.java) { 15 | validateBucketName("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz") 16 | } 17 | } 18 | 19 | fun validateBucketNameTooShort() { 20 | assertThrows(IllegalArgumentException::class.java) { 21 | validateBucketName("ab") 22 | } 23 | } 24 | 25 | fun validateBucketNameUpperCase() { 26 | assertThrows(IllegalArgumentException::class.java) { 27 | validateBucketName("Foo-bar-baz") 28 | } 29 | } 30 | 31 | fun validateBucketNameIllegalChars() { 32 | assertThrows(IllegalArgumentException::class.java) { 33 | validateBucketName("foo-bar-!baz") 34 | } 35 | } 36 | 37 | fun validateBucketNameStartsOrEndsWithDash() { 38 | assertThrows(IllegalArgumentException::class.java) { 39 | validateBucketName("-foo-bar-baz") 40 | } 41 | assertThrows(IllegalArgumentException::class.java) { 42 | validateBucketName("foo-bar-baz-") 43 | } 44 | } 45 | 46 | fun configInvalidStaticFilesBucket() { 47 | assertThrows(IllegalArgumentException::class.java) { 48 | ApplicationConfig( 49 | staticFilesBucket = "UpperCaseName", 50 | applicationName = "notUsed", 51 | stages = listOf( 52 | Stage( 53 | name = "test", 54 | deployOnUpdate = false 55 | ) 56 | ) 57 | ) 58 | } 59 | } 60 | 61 | fun configInvalidAppName() { 62 | assertThrows(IllegalArgumentException::class.java) { 63 | ApplicationConfig( 64 | applicationName = "not valid", 65 | stages = listOf( 66 | Stage( 67 | name = "test", 68 | deployOnUpdate = false 69 | ) 70 | ) 71 | ) 72 | } 73 | } 74 | 75 | fun configInvalidStaticCodeBucket() { 76 | assertThrows(IllegalArgumentException::class.java) { 77 | ApplicationConfig( 78 | codeBucket = "UpperCaseName", 79 | applicationName = "notUsed", 80 | stages = listOf( 81 | Stage( 82 | name = "test", 83 | deployOnUpdate = false 84 | ) 85 | ) 86 | ) 87 | } 88 | } 89 | 90 | fun validateNameValidName() { 91 | validateName("Foo-Bar") 92 | } 93 | 94 | fun validateNameIllegalChars() { 95 | assertThrows(IllegalArgumentException::class.java) { 96 | validateName("Foo_Bar") 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /aws/src/main/kotlin/ws/osiris/aws/KeepAlive.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.aws 2 | 3 | import com.amazonaws.services.lambda.AWSLambda 4 | import com.amazonaws.services.lambda.AWSLambdaClientBuilder 5 | import com.amazonaws.services.lambda.model.InvocationType 6 | import com.amazonaws.services.lambda.model.InvokeRequest 7 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 8 | import com.google.gson.Gson 9 | import org.slf4j.LoggerFactory 10 | import java.nio.ByteBuffer 11 | import kotlin.math.pow 12 | 13 | /** 14 | * The number of retries if the keep-alive call fails with access denied. 15 | * 16 | * Retrying is necessary because policy updates aren't visible immediately after the stack is updated. 17 | */ 18 | private const val RETRIES = 7 19 | 20 | /** 21 | * Lambda that invokes other lambdas with keep-alive messages. 22 | * 23 | * It is triggered by a CloudWatch event containing the ARN of the lambda to keep alive and 24 | * the number of instances that should be kept alive. 25 | */ 26 | class KeepAliveLambda { 27 | 28 | private val lambdaClient: AWSLambda = AWSLambdaClientBuilder.defaultClient() 29 | private val gson: Gson = Gson() 30 | 31 | fun handle(trigger: KeepAliveTrigger) { 32 | log.debug("Triggering keep-alive, count: {}, function: {}", trigger.instanceCount, trigger.functionArn) 33 | // The lambda expects a request from API Gateway of this type - this fakes it 34 | val requestEvent = APIGatewayProxyRequestEvent().apply { 35 | resource = KEEP_ALIVE_RESOURCE 36 | headers = mapOf(KEEP_ALIVE_SLEEP to trigger.sleepTimeMs.toString()) 37 | } 38 | val json = gson.toJson(requestEvent) 39 | val payloadBuffer = ByteBuffer.wrap(json.toByteArray()) 40 | val invokeRequest = InvokeRequest().apply { 41 | functionName = trigger.functionArn 42 | invocationType = InvocationType.Event.name 43 | payload = payloadBuffer 44 | } 45 | 46 | /** 47 | * Invokes multiple copies of the function, retrying if access is denied. 48 | * 49 | * The retry is necessary because policy updates aren't visible immediately after the stack is updated. 50 | */ 51 | tailrec fun invokeFunctions(attemptCount: Int = 1) { 52 | try { 53 | repeat(trigger.instanceCount) { 54 | lambdaClient.invoke(invokeRequest) 55 | } 56 | log.debug("Keep-alive complete") 57 | return 58 | } catch (e: Exception) { 59 | if (attemptCount == RETRIES) throw e 60 | // Back off retrying - sleep for 2, 4, 8, 16, ... 61 | val sleep = 1000L * (2.0.pow(attemptCount)).toLong() 62 | log.debug("Exception triggering keep-alive: {} {}, sleeping for {}ms", e.javaClass.name, e.message, sleep) 63 | Thread.sleep(sleep) 64 | } 65 | invokeFunctions(attemptCount + 1) 66 | } 67 | invokeFunctions() 68 | } 69 | 70 | companion object { 71 | private val log = LoggerFactory.getLogger(KeepAliveLambda::class.java) 72 | } 73 | } 74 | 75 | /** 76 | * Message sent from the CloudWatch event to trigger keep-alive calls. 77 | */ 78 | class KeepAliveTrigger(var functionArn: String = "", var instanceCount: Int = 0, var sleepTimeMs: Int = 200) 79 | -------------------------------------------------------------------------------- /archetype/src/main/resources/archetype-resources/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | ${groupId} 7 | ${artifactId} 8 | 1.0.0-SNAPSHOT 9 | pom 10 | 11 | 12 | ${osirisVersion} 13 | 1.9.22 14 | 15 | 16 | 17 | core 18 | local-server 19 | 20 | 21 | 22 | 23 | 24 | ${groupId} 25 | ${artifactId}-core 26 | ${project.version} 27 | 28 | 29 | ws.osiris 30 | osiris-bom 31 | ${osiris.version} 32 | pom 33 | import 34 | 35 | 36 | 37 | 38 | 39 | src/main/kotlin 40 | src/test/kotlin 41 | 42 | 43 | org.jetbrains.kotlin 44 | kotlin-maven-plugin 45 | ${kotlin.version} 46 | 47 | 48 | compile 49 | compile 50 | 51 | compile 52 | 53 | 54 | 55 | test-compile 56 | test-compile 57 | 58 | test-compile 59 | 60 | 61 | 62 | 63 | 11 64 | 65 | src/main/kotlin 66 | ${project.build.directory}/generated-sources/osiris 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | maven-deploy-plugin 76 | 77 | true 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /server/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | 7 | ws.osiris 8 | osiris 9 | 2.0.1-SNAPSHOT 10 | .. 11 | 12 | osiris-server 13 | jar 14 | Osiris Server 15 | Shared utilities for the different servers 16 | 17 | 18 | 19 | ws.osiris 20 | osiris-core 21 | 22 | 23 | org.jetbrains.kotlin 24 | kotlin-stdlib 25 | 26 | 27 | org.slf4j 28 | slf4j-api 29 | 30 | 31 | 32 | 33 | org.testng 34 | testng 35 | test 36 | 37 | 38 | org.jetbrains.kotlin 39 | kotlin-test 40 | test 41 | 42 | 43 | com.squareup.okhttp3 44 | okhttp 45 | test 46 | 47 | 48 | ws.osiris 49 | osiris-core 50 | test-jar 51 | test 52 | 53 | 54 | 55 | 56 | 57 | 58 | org.apache.maven.plugins 59 | maven-jar-plugin 60 | 61 | 62 | 63 | test-jar 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | release 74 | 75 | 76 | 77 | org.apache.maven.plugins 78 | maven-jar-plugin 79 | 80 | 81 | 82 | test-jar 83 | 84 | 85 | 86 | 87 | true 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /bom/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | 7 | ws.osiris 8 | osiris 9 | 2.0.1-SNAPSHOT 10 | .. 11 | 12 | 13 | osiris-bom 14 | pom 15 | Osiris BOM 16 | Maven BOM containing dependencies shared by Osiris and projects built using Osiris 17 | 18 | 19 | 20 | 21 | ws.osiris 22 | osiris-core 23 | ${project.version} 24 | 25 | 26 | ws.osiris 27 | osiris-local-server 28 | ${project.version} 29 | 30 | 31 | ws.osiris 32 | osiris-aws 33 | ${project.version} 34 | 35 | 36 | org.jetbrains.kotlin 37 | kotlin-stdlib 38 | ${kotlin.version} 39 | 40 | 41 | com.google.code.gson 42 | gson 43 | ${gson.version} 44 | 45 | 46 | org.slf4j 47 | slf4j-api 48 | ${slf4j.version} 49 | 50 | 51 | org.apache.logging.log4j 52 | log4j-slf4j2-impl 53 | ${log4j.version} 54 | 55 | 56 | com.amazonaws 57 | aws-lambda-java-log4j2 58 | ${aws-lambda-java-log4j2.version} 59 | 60 | 61 | org.apache.logging.log4j 62 | log4j-core 63 | ${log4j.version} 64 | 65 | 66 | com.amazonaws 67 | aws-java-sdk-bom 68 | ${aws-java-sdk.version} 69 | pom 70 | import 71 | 72 | 73 | com.amazonaws 74 | aws-lambda-java-events 75 | ${aws-lambda-java-events.version} 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /archetype/src/main/resources/archetype-resources/core/src/main/cloudformation/root.template: -------------------------------------------------------------------------------- 1 | #set($region = '${AWS::Region}') 2 | Resources: 3 | 4 | # This policy defines the permissions given to the application code. 5 | # By default it is allowed to write to CloudWatch logs and nothing else. 6 | # If the application needs access to any other resources, for example a database or message queue, then 7 | # the permissions should be added to this policy. 8 | # 9 | # The DynamoDB example project shows how to add permissions for accessing DynamoDB database tables: 10 | # https://github.com/cjkent/osiris-examples/blob/master/dynamodb/core/src/main/cloudformation/root.template#L17 11 | # 12 | # See the AWS documentation for more details: 13 | # https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html 14 | # 15 | LambdaPolicy: 16 | Type: AWS::IAM::Policy 17 | Properties: 18 | Roles: 19 | - !Ref LambdaRole 20 | PolicyName: LambdaPolicy 21 | PolicyDocument: 22 | Version: 2012-10-17 23 | Statement: 24 | - Effect: Allow 25 | Action: 26 | - "logs:*" 27 | Resource: "arn:aws:logs:*:*:*" 28 | 29 | # This role is used by the lambda function containing the application logic. 30 | # It should not be necessary to modify it except to delete ManagedPolicyArns if it is not required. 31 | LambdaRole: 32 | Type: AWS::IAM::Role 33 | Properties: 34 | AssumeRolePolicyDocument: 35 | Version: 2012-10-17 36 | Statement: 37 | - Effect: Allow 38 | Principal: 39 | Service: 40 | - lambda.amazonaws.com 41 | Action: sts:AssumeRole 42 | # This is required for running the application in a VPC. It can be deleted if VPC access is not required. 43 | ManagedPolicyArns: 44 | - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole 45 | 46 | # This references the CloudFormation file generated by Osiris. This defines the REST API in API Gateway and the 47 | # lambda that contains the application logic. 48 | # 49 | # It should not be necessary to edit it apart from adding parameters. The LambdaRole parameter must not be removed 50 | # or changed. 51 | # 52 | # There are three reasons to add parameters: 53 | # 54 | # 1) When running the application in a VPC the subnet IDs and security group IDs must be provided. 55 | # See here for details: https://github.com/cjkent/osiris/wiki/Deploying-to-a-VPC 56 | # 57 | # 2) When you are using Cognito and the user pool is defined in this file, you must pass the 58 | # ARN of the user pool to ApiStack in a parameter called CognitoUserPoolArn. 59 | # If your user pool is defined in a resource called UserPool then it will look like this: 60 | # 61 | # Parameters: 62 | # LambdaRole: !GetAtt LambdaRole.Arn 63 | # CognitoUserPoolArn: !GetAtt UserPool.Arn 64 | # 65 | # See here for details: https://github.com/cjkent/osiris/wiki/Authorization#cognito-user-pools 66 | # 67 | # 3) If you define resources in this file and need to pass values into the application code. 68 | # Any parameters defined here are automatically passed to the application code as environment variables. 69 | # 70 | ApiStack: 71 | Type: AWS::CloudFormation::Stack 72 | Properties: 73 | TemplateURL: !Sub "https://${codeS3Bucket}.s3.${region}.amazonaws.com/${rootArtifactId}.template" 74 | Parameters: 75 | LambdaRole: !GetAtt LambdaRole.Arn 76 | -------------------------------------------------------------------------------- /core/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | 7 | ws.osiris 8 | osiris 9 | 2.0.1-SNAPSHOT 10 | .. 11 | 12 | 13 | osiris-core 14 | jar 15 | Osiris Core 16 | Public API 17 | 18 | 19 | 20 | org.jetbrains.kotlin 21 | kotlin-stdlib 22 | 23 | 24 | org.slf4j 25 | slf4j-api 26 | 27 | 28 | com.google.code.gson 29 | gson 30 | 31 | 32 | 33 | 34 | org.testng 35 | testng 36 | test 37 | 38 | 39 | org.jetbrains.kotlin 40 | kotlin-test 41 | test 42 | 43 | 44 | org.apache.logging.log4j 45 | log4j-core 46 | test 47 | 48 | 49 | org.jetbrains.kotlin 50 | kotlin-reflect 51 | test 52 | 53 | 54 | com.fasterxml.jackson.module 55 | jackson-module-kotlin 56 | test 57 | 58 | 59 | 60 | 61 | 62 | 63 | org.apache.maven.plugins 64 | maven-jar-plugin 65 | 66 | 67 | 68 | test-jar 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | release 79 | 80 | 81 | 82 | org.apache.maven.plugins 83 | maven-jar-plugin 84 | 85 | 86 | 87 | test-jar 88 | 89 | 90 | 91 | 92 | true 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /aws-deploy/src/test/kotlin/ws/osiris/aws/cloudformation/TemplateTest.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.aws.cloudformation 2 | 3 | import org.intellij.lang.annotations.Language 4 | import org.testng.annotations.Test 5 | import ws.osiris.aws.ApplicationConfig 6 | import ws.osiris.aws.Stage 7 | import ws.osiris.awsdeploy.cloudformation.ApiTemplate 8 | import ws.osiris.awsdeploy.cloudformation.ResourceTemplate 9 | import ws.osiris.awsdeploy.cloudformation.Templates 10 | import ws.osiris.core.ComponentsProvider 11 | import ws.osiris.core.api 12 | import java.io.StringWriter 13 | import java.time.Duration 14 | import kotlin.test.assertEquals 15 | 16 | @Test 17 | class TemplateTest { 18 | 19 | fun apiTemplateOnly() { 20 | val apiTemplate = ApiTemplate( 21 | ResourceTemplate(listOf(), listOf(), "", "", true), 22 | "foo", 23 | "desc", 24 | null, 25 | setOf() 26 | ) 27 | val writer = StringWriter() 28 | apiTemplate.write(writer) 29 | @Language("yaml") 30 | val expected = """ 31 | | 32 | | Api: 33 | | Type: AWS::ApiGateway::RestApi 34 | | Properties: 35 | | Name: "foo" 36 | | Description: "desc" 37 | | FailOnWarnings: true 38 | | BinaryMediaTypes: [] 39 | | 40 | | CloudWatchRole: 41 | | Type: AWS::IAM::Role 42 | | Properties: 43 | | AssumeRolePolicyDocument: 44 | | Version: 2012-10-17 45 | | Statement: 46 | | - Effect: Allow 47 | | Principal: 48 | | Service: 49 | | - apigateway.amazonaws.com 50 | | Action: sts:AssumeRole 51 | | ManagedPolicyArns: 52 | | - arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs 53 | | 54 | | ApiGatewayAccount: 55 | | Type: AWS::ApiGateway::Account 56 | | Properties: 57 | | CloudWatchRoleArn: !GetAtt CloudWatchRole.Arn 58 | """.trimMargin() 59 | assertEquals(expected, writer.toString()) 60 | } 61 | 62 | fun createFromApi() { 63 | val api = api { 64 | get("/") { } 65 | get("/foo") { } 66 | get("/foo/bar") { } 67 | get("/baz") { } 68 | staticFiles { 69 | path = "/public" 70 | indexFile = "index.html" 71 | } 72 | } 73 | val config = ApplicationConfig( 74 | applicationName = "my-application", 75 | lambdaMemorySizeMb = 512, 76 | lambdaTimeout = Duration.ofSeconds(10), 77 | environmentVariables = mapOf( 78 | "FOO" to "foo value", 79 | "BAR" to "bar value" 80 | ), 81 | stages = listOf( 82 | Stage( 83 | name = "dev", 84 | deployOnUpdate = true, 85 | variables = mapOf("STAGE_VAR" to "dev value") 86 | ), 87 | Stage( 88 | name = "prod", 89 | deployOnUpdate = false, 90 | variables = mapOf("STAGE_VAR" to "prod value") 91 | ) 92 | ) 93 | ) 94 | Templates.create( 95 | api, 96 | config, 97 | setOf("UserParam1", "UserParam2"), 98 | "com.example.GeneratedLambda", 99 | "testHash", 100 | "staticHash", 101 | "testApi.code", 102 | "testApi.jar", 103 | "dev", 104 | "12345678" 105 | ) 106 | // TODO assertions 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /archetype/src/main/resources/archetype-resources/core/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | 7 | ${groupId} 8 | ${rootArtifactId} 9 | ${version} 10 | .. 11 | 12 | ${artifactId} 13 | jar 14 | 15 | 16 | 17 | ws.osiris 18 | osiris-core 19 | 20 | 21 | ws.osiris 22 | osiris-aws 23 | 24 | 25 | org.jetbrains.kotlin 26 | kotlin-stdlib 27 | 28 | 29 | com.google.code.gson 30 | gson 31 | 32 | 33 | org.apache.logging.log4j 34 | log4j-slf4j2-impl 35 | 36 | 37 | com.amazonaws 38 | aws-lambda-java-log4j2 39 | 40 | 41 | 42 | 43 | src/main/kotlin 44 | src/test/kotlin 45 | 46 | 50 | 51 | maven-assembly-plugin 52 | 3.0.0 53 | 54 | 55 | ${project.basedir}/src/assembly/dist.xml 56 | 57 | 58 | 59 | 60 | make-assembly 61 | package 62 | 63 | single 64 | 65 | 66 | 67 | 68 | 69 | 70 | ws.osiris 71 | osiris-maven-plugin 72 | ${osiris.version} 73 | 74 | ${package} 75 | 76 | 77 | 78 | generate-cloudformation 79 | 80 | generate-cloudformation 81 | 82 | 83 | 84 | 85 | deploy 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /aws-deploy/src/main/kotlin/ws/osiris/awsdeploy/Profile.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.awsdeploy 2 | 3 | import com.amazonaws.ClientConfiguration 4 | import com.amazonaws.auth.AWSCredentialsProvider 5 | import com.amazonaws.auth.DefaultAWSCredentialsProviderChain 6 | import com.amazonaws.auth.profile.ProfileCredentialsProvider 7 | import com.amazonaws.regions.AwsProfileRegionProvider 8 | import com.amazonaws.regions.DefaultAwsRegionProviderChain 9 | import com.amazonaws.services.apigateway.AmazonApiGateway 10 | import com.amazonaws.services.apigateway.AmazonApiGatewayClientBuilder 11 | import com.amazonaws.services.cloudformation.AmazonCloudFormation 12 | import com.amazonaws.services.cloudformation.AmazonCloudFormationClientBuilder 13 | import com.amazonaws.services.lambda.AWSLambda 14 | import com.amazonaws.services.lambda.AWSLambdaClientBuilder 15 | import com.amazonaws.services.s3.AmazonS3 16 | import com.amazonaws.services.s3.AmazonS3ClientBuilder 17 | import com.amazonaws.services.securitytoken.AWSSecurityTokenServiceClientBuilder 18 | import com.amazonaws.services.securitytoken.model.GetCallerIdentityRequest 19 | 20 | /** 21 | * Provides AWS clients and related values (region, account ID). 22 | */ 23 | class AwsProfile private constructor(private val credentialsProvider: AWSCredentialsProvider, val region: String) { 24 | 25 | private val clientConfiguration = ClientConfiguration() 26 | .withSocketTimeout(120_000) 27 | .withConnectionTimeout(20_000) 28 | .withMaxErrorRetry(20) 29 | 30 | val s3Client: AmazonS3 by lazy { 31 | AmazonS3ClientBuilder.standard() 32 | .withCredentials(credentialsProvider) 33 | .withRegion(region) 34 | .withClientConfiguration(clientConfiguration) 35 | .build() 36 | } 37 | 38 | val apiGatewayClient: AmazonApiGateway by lazy { 39 | AmazonApiGatewayClientBuilder.standard() 40 | .withCredentials(credentialsProvider) 41 | .withRegion(region) 42 | .withClientConfiguration(clientConfiguration) 43 | .build() 44 | } 45 | 46 | val cloudFormationClient: AmazonCloudFormation by lazy { 47 | AmazonCloudFormationClientBuilder.standard() 48 | .withCredentials(credentialsProvider) 49 | .withRegion(region) 50 | .withClientConfiguration(clientConfiguration) 51 | .build() 52 | } 53 | 54 | val lambdaClient: AWSLambda by lazy { 55 | AWSLambdaClientBuilder.standard() 56 | .withCredentials(credentialsProvider) 57 | .withRegion(region) 58 | .withClientConfiguration(clientConfiguration) 59 | .build() 60 | } 61 | 62 | val accountId: String by lazy { 63 | val stsClient = AWSSecurityTokenServiceClientBuilder.standard() 64 | .withCredentials(credentialsProvider) 65 | .withRegion(region) 66 | .build() 67 | stsClient.getCallerIdentity(GetCallerIdentityRequest()).account 68 | } 69 | 70 | companion object { 71 | 72 | /** 73 | * Returns a profile that gets the credentials and region using the default AWS mechanism. 74 | * 75 | * See [here](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html#credentials-default) 76 | * for details. 77 | */ 78 | fun default(): AwsProfile = AwsProfile( 79 | DefaultAWSCredentialsProviderChain(), 80 | DefaultAwsRegionProviderChain().region 81 | ) 82 | 83 | /** 84 | * Returns a profile that takes the credentials and region from a named profile in the AWS credentials and 85 | * config files. 86 | */ 87 | fun named(profileName: String): AwsProfile = AwsProfile( 88 | ProfileCredentialsProvider(profileName), 89 | AwsProfileRegionProvider(profileName).region 90 | ) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /integration/src/main/kotlin/ws/osiris/integration/IntegrationTestApi.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.integration 2 | 3 | import com.google.gson.Gson 4 | import ws.osiris.core.ComponentsProvider 5 | import ws.osiris.core.DataNotFoundException 6 | import ws.osiris.core.ForbiddenException 7 | import ws.osiris.core.HttpHeaders 8 | import ws.osiris.core.MimeTypes 9 | import ws.osiris.core.api 10 | 11 | interface TestComponents : ComponentsProvider { 12 | val gson: Gson 13 | val name: String 14 | val size: Int 15 | } 16 | 17 | class TestComponentsImpl(override val name: String, override val size: Int) : TestComponents { 18 | 19 | constructor() : this("Bob", 42) 20 | 21 | override val gson: Gson = Gson() 22 | } 23 | 24 | /** 25 | * Simple class demonstrating automatic conversion to JSON. 26 | * 27 | * Produces JSON like: 28 | * 29 | * {"message":"hello, world!"} 30 | */ 31 | internal data class JsonMessage(val message: String) 32 | 33 | /** 34 | * Simple class demonstrating creating an object from JSON in the request body. 35 | * 36 | * Expects JSON like: 37 | * 38 | * {"name":"Bob"} 39 | */ 40 | internal data class JsonPayload(val name: String) 41 | 42 | /** 43 | * An API definition that can be deployed to AWS and have integration tests run against it. 44 | */ 45 | val api = api { 46 | 47 | staticFiles { 48 | path = "/public" 49 | indexFile = "index.html" 50 | } 51 | get("/") { 52 | mapOf("message" to "hello, root!") 53 | } 54 | get("/helloworld") { 55 | // return a map that is automatically converted to JSON 56 | mapOf("message" to "hello, world!") 57 | } 58 | get("/helloplain") { req -> 59 | // return a response with customised headers 60 | req.responseBuilder() 61 | .header(HttpHeaders.CONTENT_TYPE, MimeTypes.TEXT_PLAIN) 62 | .build("hello, world!") 63 | } 64 | get("/hello/queryparam1") { req -> 65 | // get an optional query parameter 66 | val name = req.queryParams.optional("name") ?: "world" 67 | mapOf("message" to "hello, $name!") 68 | } 69 | get("/hello/queryparam2") { req -> 70 | // get a required query parameter 71 | val name = req.queryParams["name"] 72 | mapOf("message" to "hello, $name!") 73 | } 74 | // use path() to group multiple endpoints under the same sub-path 75 | path("/hello") { 76 | get("/{name}") { req -> 77 | // get a path parameter 78 | val name = req.pathParams["name"] 79 | // this will be automatically converted to a JSON object like {"message":"hello, Bob!"} 80 | JsonMessage("hello, $name!") 81 | } 82 | get("/env") { 83 | // use the name property from TestComponents for the name 84 | JsonMessage("hello, $name!") 85 | } 86 | } 87 | post("/foo") { req -> 88 | // expecting a JSON payload like {"name":"Bob"} 89 | val payload = gson.fromJson(req.body(), JsonPayload::class.java) 90 | // this will be automatically converted to a JSON object like {"message":"hello, Bob!"} 91 | JsonMessage("hello, ${payload.name}!") 92 | } 93 | // Endpoints demonstrating the mapping of exceptions to responses 94 | // Demonstrates mapping DataNotFoundException to a 404 95 | get("/foo/{fooId}") { req -> 96 | val fooId = req.pathParams["fooId"] 97 | when (fooId) { 98 | "123" -> JsonMessage("foo 123 found") 99 | else -> throw DataNotFoundException("No foo found with ID $fooId") 100 | } 101 | } 102 | // Status 403 (forbidden) 103 | get("/forbidden") { 104 | throw ForbiddenException("top secret") 105 | } 106 | // Status 500 (server error). Returned when there is not specific handler for the exception type 107 | get("/servererror") { 108 | throw RuntimeException("oh no!") 109 | } 110 | } 111 | 112 | /** 113 | * Creates the components used by the test API. 114 | */ 115 | fun createComponents(): TestComponents = TestComponentsImpl() 116 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/ws/osiris/gradle/ProjectPlugin.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.gradle 2 | 3 | import org.gradle.api.DefaultTask 4 | import org.gradle.api.Plugin 5 | import org.gradle.api.Project 6 | import org.gradle.api.tasks.TaskAction 7 | import java.nio.file.Files 8 | import java.nio.file.Path 9 | import java.nio.file.Paths 10 | 11 | /** 12 | * Plugin to generate an empty Osiris project with build files and example code. 13 | */ 14 | class OsirisProjectPlugin : Plugin { 15 | 16 | override fun apply(project: Project) { 17 | project.tasks.create("generateProject", OsirisProjectTask::class.java) 18 | } 19 | } 20 | /** 21 | * Task to generate an empty project. 22 | */ 23 | open class OsirisProjectTask : DefaultTask() { 24 | 25 | @TaskAction 26 | fun generateProject() { 27 | checkPaths("core", "local-server") 28 | if (!project.hasProperty("package")) { 29 | throw IllegalStateException("Please specify the application package using '-Ppackage=...', " + 30 | "for example: '-Ppackage=com.example.application'") 31 | } 32 | val rootPackage = project.property("package") as String 33 | for (resource in resources) { 34 | val bytes = javaClass.getResourceAsStream(resource).use { it.readBytes() } 35 | val fileStr = String(bytes, Charsets.UTF_8) 36 | val replacedFile = fileStr 37 | .replace("\${package}", rootPackage) 38 | .replace("\${rootArtifactId}", project.name) 39 | .replace(Regex("""#set.*?\n"""), "") 40 | .replace("\${region}", "\${AWS::Region}") 41 | .replace("\${osirisVersion}", project.properties["osirisVersion"].toString()) 42 | .replace("\${kotlinVersion}", project.properties["kotlinVersion"].toString()) 43 | val projectDir = project.projectDir.toPath() 44 | val path = projectDir.resolve(insertPackageDirs(resource, rootPackage)) 45 | Files.createDirectories(path.parent) 46 | logger.info("Writing file {} to {}", path.fileName, path.toAbsolutePath()) 47 | Files.write(path, replacedFile.toByteArray(Charsets.UTF_8)) 48 | } 49 | Files.createDirectories(project.projectDir.toPath().resolve("core/src/main/static")) 50 | } 51 | 52 | private fun insertPackageDirs(resource: String, rootPackage: String): Path { 53 | val match = packageRegex.find(resource) ?: return Paths.get(resource.substring("/archetype-resources/".length)) 54 | val packagePath = rootPackage.replace('.', '/') 55 | val groupValues = match.groupValues 56 | val path = "${groupValues[1]}/src/main/kotlin/$packagePath/${groupValues[2]}/${groupValues[3]}" 57 | return Paths.get(path) 58 | } 59 | 60 | private fun checkPaths(vararg paths: String) { 61 | for (path in paths) { 62 | if (Files.exists(Paths.get(path))) { 63 | throw IllegalStateException("'$path' already exists, cannot create project") 64 | } 65 | } 66 | } 67 | 68 | companion object { 69 | private val resources: List = listOf( 70 | // these come from this project 71 | "/archetype-resources/settings.gradle", 72 | "/archetype-resources/build.gradle", 73 | "/archetype-resources/core/build.gradle", 74 | "/archetype-resources/local-server/build.gradle", 75 | // these come from the Maven archetype 76 | "/archetype-resources/core/src/main/resources/log4j2.xml", 77 | "/archetype-resources/core/src/main/kotlin/core/generated/Generated.kt", 78 | "/archetype-resources/core/src/main/kotlin/core/ApiDefinition.kt", 79 | "/archetype-resources/core/src/main/kotlin/core/Config.kt", 80 | "/archetype-resources/core/src/main/cloudformation/root.template", 81 | "/archetype-resources/local-server/src/main/resources/log4j2.xml", 82 | "/archetype-resources/local-server/src/main/kotlin/localserver/Main.kt" 83 | ) 84 | 85 | private val packageRegex: Regex = Regex("/archetype-resources/(.+?)/src/main/kotlin/(.+?)/(.*)") 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /integration/src/test/kotlin/ws/osiris/integration/IntegrationTests.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.integration 2 | 3 | import com.google.gson.Gson 4 | import org.testng.annotations.Test 5 | import ws.osiris.core.ContentType 6 | import ws.osiris.core.HttpHeaders 7 | import ws.osiris.core.InMemoryTestClient 8 | import ws.osiris.core.JSON_CONTENT_TYPE 9 | import ws.osiris.core.MimeTypes 10 | import ws.osiris.core.TestClient 11 | import ws.osiris.localserver.LocalHttpTestClient 12 | import java.nio.file.Paths 13 | import kotlin.test.assertEquals 14 | import kotlin.test.assertTrue 15 | 16 | private val components: TestComponents = TestComponentsImpl("Bob", 42) 17 | 18 | private const val STATIC_DIR = "src/test/static" 19 | 20 | @Test 21 | class InMemoryIntegrationTest { 22 | 23 | fun testApiInMemory() { 24 | val client = InMemoryTestClient.create(components, api, Paths.get(STATIC_DIR)) 25 | assertApi(client) 26 | } 27 | } 28 | 29 | @Test 30 | class LocalHttpIntegrationTest { 31 | 32 | fun testApiLocalHttpServer() { 33 | LocalHttpTestClient.create(api, components, STATIC_DIR).use { assertApi(it) } 34 | } 35 | } 36 | 37 | internal fun assertApi(client: TestClient) { 38 | val gson = Gson() 39 | fun Any?.parseJson(): Map<*, *> { 40 | val json = this as? String ?: throw IllegalArgumentException("Value is not a string: $this") 41 | return gson.fromJson(json, Map::class.java) 42 | } 43 | val rootResponse = client.get("/") 44 | assertEquals(mapOf("message" to "hello, root!"), rootResponse.body.parseJson()) 45 | assertEquals(JSON_CONTENT_TYPE, ContentType.parse(rootResponse.headers[HttpHeaders.CONTENT_TYPE]!!)) 46 | assertEquals(200, rootResponse.status) 47 | 48 | val response1 = client.get("/helloworld") 49 | assertEquals(mapOf("message" to "hello, world!"), response1.body.parseJson()) 50 | assertEquals(JSON_CONTENT_TYPE, ContentType.parse(rootResponse.headers[HttpHeaders.CONTENT_TYPE]!!)) 51 | assertEquals(200, response1.status) 52 | 53 | val response2 = client.get("/helloplain") 54 | assertEquals("hello, world!", response2.body) 55 | assertEquals(MimeTypes.TEXT_PLAIN, response2.headers[HttpHeaders.CONTENT_TYPE]) 56 | 57 | assertEquals(mapOf("message" to "hello, world!"), client.get("/hello/queryparam1").body.parseJson()) 58 | assertEquals(mapOf("message" to "hello, Alice!"), client.get("/hello/queryparam1?name=Alice").body.parseJson()) 59 | assertEquals(mapOf("message" to "hello, Tom!"), client.get("/hello/queryparam2?name=Tom").body.parseJson()) 60 | assertEquals(mapOf("message" to "hello, Peter!"), client.get("/hello/Peter").body.parseJson()) 61 | assertEquals(mapOf("message" to "hello, Bob!"), client.get("/hello/env").body.parseJson()) 62 | assertEquals(mapOf("message" to "hello, Max!"), client.post("/foo", "{\"name\":\"Max\"}").body.parseJson()) 63 | 64 | val response3 = client.get("/hello/queryparam2") 65 | assertEquals(400, response3.status) 66 | assertEquals("No value named 'name'", response3.body) 67 | 68 | assertEquals(mapOf("message" to "foo 123 found"), client.get("/foo/123").body.parseJson()) 69 | val response5 = client.get("/foo/234") 70 | assertEquals(404, response5.status) 71 | assertEquals("No foo found with ID 234", response5.body) 72 | 73 | val response6 = client.get("/servererror") 74 | assertEquals(500, response6.status) 75 | assertEquals("Server Error", response6.body) 76 | 77 | val response7 = client.get("/public") 78 | assertEquals(200, response7.status) 79 | val body7 = response7.body 80 | assertTrue(body7 is String && body7.contains("hello, world!")) 81 | 82 | val response8 = client.get("/public/") 83 | assertEquals(200, response8.status) 84 | val body8 = response8.body 85 | assertTrue(body8 is String && body8.contains("hello, world!")) 86 | 87 | val response9 = client.get("/public/index.html") 88 | assertEquals(200, response9.status) 89 | val body9 = response9.body 90 | assertTrue(body9 is String && body9.contains("hello, world!")) 91 | 92 | val response10 = client.get("/public/baz/bar.html") 93 | assertEquals(200, response10.status) 94 | val body10 = response10.body 95 | assertTrue(body10 is String && body10.contains("hello, bar!")) 96 | } 97 | -------------------------------------------------------------------------------- /integration/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | osiris 7 | ws.osiris 8 | 2.0.1-SNAPSHOT 9 | .. 10 | 11 | 4.0.0 12 | osiris-integration 13 | Osiris Integration 14 | Integration tests 15 | 16 | 17 | 18 | ws.osiris 19 | osiris-core 20 | 21 | 22 | ws.osiris 23 | osiris-local-server 24 | 25 | 26 | ws.osiris 27 | osiris-aws 28 | 29 | 30 | com.google.code.gson 31 | gson 32 | 33 | 34 | 35 | 36 | org.testng 37 | testng 38 | test 39 | 40 | 41 | ws.osiris 42 | osiris-aws-deploy 43 | test 44 | 45 | 46 | ws.osiris 47 | osiris-core 48 | test-jar 49 | test 50 | 51 | 52 | ws.osiris 53 | osiris-server 54 | test-jar 55 | test 56 | 57 | 58 | ws.osiris 59 | osiris-local-server 60 | test-jar 61 | test 62 | 63 | 64 | com.squareup.okhttp3 65 | okhttp 66 | test 67 | 68 | 69 | org.jetbrains.kotlin 70 | kotlin-test 71 | test 72 | 73 | 74 | com.amazonaws 75 | aws-java-sdk-cloudformation 76 | test 77 | 78 | 79 | com.amazonaws 80 | aws-java-sdk-s3 81 | test 82 | 83 | 84 | org.slf4j 85 | slf4j-api 86 | test 87 | 88 | 89 | org.apache.logging.log4j 90 | log4j-slf4j2-impl 91 | test 92 | 93 | 94 | org.apache.logging.log4j 95 | log4j-core 96 | test 97 | 98 | 99 | org.apache.logging.log4j 100 | log4j-api 101 | test 102 | 103 | 104 | com.fasterxml.jackson.core 105 | jackson-databind 106 | test 107 | 108 | 109 | com.fasterxml.jackson.module 110 | jackson-module-kotlin 111 | test 112 | 113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /core/src/test/kotlin/ws/osiris/core/StandardFilterTest.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.core 2 | 3 | import com.google.gson.Gson 4 | import org.testng.annotations.Test 5 | import kotlin.test.assertEquals 6 | 7 | /** 8 | * Tests for the functionality provided by the standard set of filters that are included by default. 9 | */ 10 | @Test 11 | class StandardFilterTest { 12 | 13 | fun defaultContentType() { 14 | val api = api { 15 | get("/foo") { _ -> 16 | "Foo" 17 | } 18 | } 19 | val client = InMemoryTestClient.create(api) 20 | val (_, headers, _) = client.get("/foo") 21 | assertEquals(JSON_CONTENT_TYPE.header, headers[HttpHeaders.CONTENT_TYPE]) 22 | } 23 | 24 | /** 25 | * Tests the automatic encoding of the response body into a JSON string. 26 | * 27 | * Handling of body types by content type: 28 | * * content type = JSON 29 | * * null - no body 30 | * * string - assumed to be JSON, used as-is 31 | * * ByteArray - base64 encoded - does API Gateway return this as binary? or a base64 encoded string? 32 | * * object - converted to a JSON string using Jackson 33 | * * content type != JSON 34 | * * null - no body 35 | * * string - used as-is, no base64 - Jackson should handle escaping when AWS does the conversion 36 | * * ByteArray - base64 encoded - does API Gateway return this as binary? or a base64 encoded string? 37 | * * any other type throws an exception. or should it just use toString()? seems friendlier 38 | */ 39 | fun serialiseObjectsToJson() { 40 | val api = api { 41 | get("/nullbody") { req -> 42 | req.responseBuilder().build(null) 43 | } 44 | get("/stringbody") { _ -> 45 | """{"foo":"abc"}""" 46 | } 47 | get("/mapbody") { _ -> 48 | mapOf("foo" to 42, "bar" to "Bar") 49 | } 50 | get("/objectbody") { _ -> 51 | BodyObject(42, "Bar") 52 | } 53 | } 54 | val client = InMemoryTestClient.create(api) 55 | assertEquals(null, client.get("/nullbody").body) 56 | assertEquals("""{"foo":"abc"}""", client.get("/stringbody").body) 57 | assertEquals("""{"foo":42,"bar":"Bar"}""", client.get("/mapbody").body) 58 | assertEquals("""{"foo":42,"bar":"Bar"}""", client.get("/objectbody").body) 59 | } 60 | 61 | // TODO serialisation when the content type isn't JSON. not a high priority for now 62 | 63 | @Test 64 | fun exceptionMapping() { 65 | val api = api { 66 | get("/badrequest") { _ -> 67 | throw IllegalArgumentException("illegal arg") 68 | } 69 | get("/notfound") { _ -> 70 | throw DataNotFoundException("not found") 71 | } 72 | get("/badjson") { _ -> 73 | // This throws a JsonSyntaxException which is mapped to a bad request 74 | Gson().fromJson("this is invalid JSON", Map::class.java) 75 | } 76 | get("/forbidden") { _ -> 77 | throw ForbiddenException("top secret") 78 | } 79 | get("/servererror") { _ -> 80 | throw RuntimeException("oh no!") 81 | } 82 | } 83 | val client = InMemoryTestClient.create(api) 84 | val (status1, _, body1) = client.get("/badrequest") 85 | assertEquals(400, status1) 86 | assertEquals("illegal arg", body1) 87 | 88 | val (status2, _, body2) = client.get("/notfound") 89 | assertEquals(404, status2) 90 | assertEquals("not found", body2) 91 | 92 | val (status3, _, body3) = client.get("/forbidden") 93 | assertEquals(403, status3) 94 | assertEquals("top secret", body3) 95 | 96 | val (status4, _, body4) = client.get("/servererror") 97 | assertEquals(500, status4) 98 | assertEquals("Server Error", body4) 99 | } 100 | 101 | fun testNoFilters() { 102 | val api = api { 103 | 104 | globalFilters = listOf() 105 | 106 | get("/foo") { _ -> 107 | "Foo" 108 | } 109 | } 110 | val client = InMemoryTestClient.create(api) 111 | val (_, headers, _) = client.get("/foo") 112 | assertEquals(null, headers[HttpHeaders.CONTENT_TYPE]) 113 | } 114 | 115 | //-------------------------------------------------------------------------------------------------- 116 | 117 | private class BodyObject(val foo: Int, val bar: String) 118 | } 119 | -------------------------------------------------------------------------------- /local-server/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | 7 | ws.osiris 8 | osiris 9 | 2.0.1-SNAPSHOT 10 | .. 11 | 12 | 13 | osiris-local-server 14 | jar 15 | Osiris Local Server 16 | Utilities for running in a local HTTP server 17 | 18 | 19 | 20 | ws.osiris 21 | osiris-core 22 | 23 | 24 | ws.osiris 25 | osiris-server 26 | 27 | 28 | ws.osiris 29 | osiris-aws 30 | 31 | 32 | org.jetbrains.kotlin 33 | kotlin-stdlib 34 | 35 | 36 | org.slf4j 37 | slf4j-api 38 | 39 | 40 | org.eclipse.jetty 41 | jetty-servlet 42 | 43 | 44 | jakarta.servlet 45 | jakarta.servlet-api 46 | 47 | 48 | com.beust 49 | jcommander 50 | 51 | 52 | 53 | org.apache.logging.log4j 54 | log4j-core 55 | runtime 56 | 57 | 58 | 59 | 60 | org.testng 61 | testng 62 | test 63 | 64 | 65 | ws.osiris 66 | osiris-core 67 | test-jar 68 | test 69 | 70 | 71 | com.squareup.okhttp3 72 | okhttp 73 | test 74 | 75 | 76 | ws.osiris 77 | osiris-server 78 | test-jar 79 | test 80 | 81 | 82 | com.fasterxml.jackson.module 83 | jackson-module-kotlin 84 | test 85 | 86 | 87 | 88 | 89 | 90 | 91 | org.apache.maven.plugins 92 | maven-jar-plugin 93 | 94 | 95 | 96 | test-jar 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | release 107 | 108 | 109 | 110 | org.apache.maven.plugins 111 | maven-jar-plugin 112 | 113 | 114 | 115 | test-jar 116 | 117 | 118 | 119 | 120 | true 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /integration/src/test/e2e-test/code/ApiDefinition.kt: -------------------------------------------------------------------------------- 1 | // TODO allow this to be replaced so the end-to-end test can handle packages other than the default 2 | package com.example.osiris.core 3 | 4 | import com.google.gson.Gson 5 | import ws.osiris.aws.IamAuth 6 | import ws.osiris.core.MimeTypes 7 | import ws.osiris.core.DataNotFoundException 8 | import ws.osiris.core.ForbiddenException 9 | import ws.osiris.core.ComponentsProvider 10 | import ws.osiris.core.HttpHeaders 11 | import ws.osiris.core.api 12 | 13 | /** 14 | * The name of an environment variable used to pass configuration to the code that handles the HTTP requests. 15 | * 16 | * The values of environment variables can be specified in the `environmentVariables` configuration element in the 17 | * Maven plugin. For example: 18 | * 19 | * 20 | * Example value 21 | * 22 | */ 23 | const val EXAMPLE_ENVIRONMENT_VARIABLE = "EXAMPLE_ENVIRONMENT_VARIABLE" 24 | 25 | /** The API. */ 26 | val api = api { 27 | 28 | staticFiles { 29 | path = "/public" 30 | indexFile = "index.html" 31 | } 32 | get("/") { _ -> 33 | mapOf("message" to "hello, root!") 34 | } 35 | get("/helloworld") { _ -> 36 | // return a map that is automatically converted to JSON 37 | mapOf("message" to "hello, world!") 38 | } 39 | get("/helloplain") { req -> 40 | // return a response with customised headers 41 | req.responseBuilder() 42 | .header(HttpHeaders.CONTENT_TYPE, MimeTypes.TEXT_PLAIN) 43 | .build("hello, world!") 44 | } 45 | get("/hello/queryparam1") { req -> 46 | // get an optional query parameter 47 | val name = req.queryParams.optional("name") ?: "world" 48 | mapOf("message" to "hello, $name!") 49 | } 50 | get("/hello/queryparam2") { req -> 51 | // get a required query parameter 52 | val name = req.queryParams["name"] 53 | mapOf("message" to "hello, $name!") 54 | } 55 | // use path() to group multiple endpoints under the same sub-path 56 | path("/hello") { 57 | get("/{name}") { req -> 58 | // get a path parameter 59 | val name = req.pathParams["name"] 60 | // this will be automatically converted to a JSON object like {"message":"hello, Bob!"} 61 | JsonMessage("hello, $name!") 62 | } 63 | get("/env") { _ -> 64 | // use the name property from TestComponents for the name 65 | JsonMessage("hello, $name!") 66 | } 67 | } 68 | // require authorisation for all endpoints inside the auth block 69 | auth(IamAuth) { 70 | // this will be inaccessible unless a policy is created and attached to the calling user, role or group 71 | get("/topsecret") { req -> 72 | JsonMessage("For your eyes only") 73 | } 74 | } 75 | post("/foo") { req -> 76 | val gson = Gson() 77 | val payload = gson.fromJson>(req.requireBody(String::class), Map::class.java) 78 | // this will be automatically converted to a JSON object like {"message":"hello, Bob!"} 79 | JsonMessage("hello, ${payload["name"]}!") 80 | } 81 | // Endpoints demonstrating the mapping of exceptions to responses 82 | // Demonstrates mapping DataNotFoundException to a 404 83 | get("/foo/{fooId}") { req -> 84 | val fooId = req.pathParams["fooId"] 85 | when (fooId) { 86 | "123" -> JsonMessage("foo 123 found") 87 | else -> throw DataNotFoundException("No foo found with ID $fooId") 88 | } 89 | } 90 | // Status 403 (forbidden) 91 | get("/forbidden") { req -> 92 | throw ForbiddenException("top secret") 93 | } 94 | // Status 500 (server error). Returned when there is not specific handler for the exception type 95 | get("/servererror") { req -> 96 | throw RuntimeException("oh no!") 97 | } 98 | } 99 | 100 | /** 101 | * Creates the components used by the test API. 102 | */ 103 | fun createComponents(): ExampleComponents = ExampleComponentsImpl() 104 | 105 | /** 106 | * A trivial set of components that exposes a simple property to the request handling code in the API definition. 107 | */ 108 | interface ExampleComponents : ComponentsProvider { 109 | val name: String 110 | } 111 | 112 | /** 113 | * An implementation of `ExampleComponents` that uses an environment variable to populate its data. 114 | */ 115 | class ExampleComponentsImpl : ExampleComponents { 116 | override val name: String = "Bob" 117 | } 118 | 119 | /** 120 | * Simple class demonstrating automatic conversion to JSON. 121 | * 122 | * Produces JSON like: 123 | * 124 | * {"message":"hello, world!"} 125 | */ 126 | data class JsonMessage(val message: String) 127 | -------------------------------------------------------------------------------- /core/src/test/kotlin/ws/osiris/core/InMemoryTestClientTest.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.core 2 | 3 | import com.google.gson.Gson 4 | import org.testng.Assert 5 | import org.testng.annotations.Test 6 | import java.nio.file.Paths 7 | import kotlin.test.assertEquals 8 | import kotlin.test.assertTrue 9 | 10 | private val components: ComponentsProvider = object : ComponentsProvider {} 11 | 12 | private val staticDir = Paths.get("src/test/static") 13 | 14 | @Test 15 | class InMemoryTestClientTest { 16 | 17 | fun get() { 18 | val api = api { 19 | get("/foo") { 20 | "hello, world!" 21 | } 22 | } 23 | val client = InMemoryTestClient.create(components, api) 24 | val response = client.get("/foo") 25 | val body = response.body 26 | assertEquals(response.status, 200) 27 | assertTrue(body == "hello, world!") 28 | } 29 | 30 | fun staticFiles() { 31 | val api = api { 32 | staticFiles { 33 | path = "/public" 34 | } 35 | } 36 | val client = InMemoryTestClient.create(components, api, staticDir) 37 | 38 | val response1 = client.get("/public/index.html") 39 | val body1 = response1.body 40 | assertEquals(response1.status, 200) 41 | assertTrue(body1 is String && body1.contains("hello, world!")) 42 | 43 | val response2 = client.get("/public/foo/bar.html") 44 | val body2 = response2.body 45 | assertEquals(response2.status, 200) 46 | assertTrue(body2 is String && body2.contains("hello, bar!")) 47 | } 48 | 49 | fun staticFilesIndexFile() { 50 | val api = api { 51 | staticFiles { 52 | path = "/public" 53 | indexFile = "index.html" 54 | } 55 | } 56 | val client = InMemoryTestClient.create(components, api, staticDir) 57 | 58 | val response1 = client.get("/public") 59 | val body1 = response1.body 60 | assertTrue(body1 is String && body1.contains("hello, world!")) 61 | 62 | val response2 = client.get("/public/") 63 | val body2 = response2.body 64 | assertTrue(body2 is String && body2.contains("hello, world!")) 65 | 66 | val response3 = client.get("/public/index.html") 67 | val body3 = response3.body 68 | assertTrue(body3 is String && body3.contains("hello, world!")) 69 | } 70 | 71 | fun staticFilesAtRoot() { 72 | val api = api { 73 | staticFiles { 74 | path = "/" 75 | indexFile = "index.html" 76 | } 77 | get("/hello") { 78 | "get hello" 79 | } 80 | } 81 | val client = InMemoryTestClient.create(components, api, staticDir) 82 | 83 | val response1 = client.get("") 84 | val body1 = response1.body 85 | assertTrue(body1 is String && body1.contains("hello, world!")) 86 | 87 | val response2 = client.get("/") 88 | val body2 = response2.body 89 | assertTrue(body2 is String && body2.contains("hello, world!")) 90 | 91 | val response3 = client.get("/index.html") 92 | val body3 = response3.body 93 | assertTrue(body3 is String && body3.contains("hello, world!")) 94 | 95 | val response4 = client.get("/foo/bar.html") 96 | val body4 = response4.body 97 | assertTrue(body4 is String && body4.contains("hello, bar!")) 98 | 99 | val response5 = client.get("/hello") 100 | val body5 = response5.body 101 | Assert.assertTrue(body5 is String && body5.contains("get hello")) 102 | } 103 | 104 | fun staticFilesNestedInPath() { 105 | val api = api { 106 | path("/foo") { 107 | staticFiles { 108 | path = "/public" 109 | } 110 | } 111 | } 112 | val client = InMemoryTestClient.create(components, api, staticDir) 113 | 114 | val response1 = client.get("/foo/public/index.html") 115 | val body1 = response1.body 116 | assertEquals(response1.status, 200) 117 | assertTrue(body1 is String && body1.contains("hello, world!")) 118 | 119 | val response2 = client.get("/foo/public/foo/bar.html") 120 | val body2 = response2.body 121 | assertEquals(response2.status, 200) 122 | assertTrue(body2 is String && body2.contains("hello, bar!")) 123 | } 124 | 125 | fun requestContextFactory() { 126 | val api = api { 127 | get("/hello") { req -> 128 | mapOf("context" to req.context.params) 129 | } 130 | } 131 | val requestContextFactory = RequestContextFactory.fixed("stage" to "dev", "foo" to "bar") 132 | val client = InMemoryTestClient.create(components, api, requestContextFactory = requestContextFactory) 133 | val response = client.get("/hello") 134 | val body = response.body as String 135 | val bodyMap = Gson().fromJson(body, Map::class.java) 136 | Assert.assertEquals(bodyMap["context"], mapOf("stage" to "dev", "foo" to "bar")) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /core/src/test/kotlin/ws/osiris/core/HttpTest.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.core 2 | 3 | import org.testng.annotations.Test 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertFailsWith 6 | 7 | @Test 8 | class RequestTest { 9 | 10 | fun withAttribute() { 11 | val request1 = Request(HttpMethod.GET, "/foo", Params(), Params(), Params(), Params()) 12 | val request2 = request1.withAttribute("foo", "bar") 13 | val request3 = request2.withAttribute("baz", 123) 14 | assertEquals(mapOf(), request1.attributes) 15 | assertEquals(mapOf("foo" to "bar"), request2.attributes) 16 | assertEquals(mapOf("foo" to "bar", "baz" to 123), request3.attributes) 17 | } 18 | 19 | fun attribute() { 20 | val attrs = mapOf("foo" to "bar", "baz" to 123) 21 | val request = Request(HttpMethod.GET, "/foo", Params(), Params(), Params(), Params(), attributes = attrs) 22 | val foo = request.attribute("foo") 23 | val baz = request.attribute("baz") 24 | assertEquals("bar", foo) 25 | assertEquals(123, baz) 26 | assertFailsWith(IllegalStateException::class) { request.attribute("foo") } 27 | assertFailsWith(IllegalStateException::class) { request.attribute("baz") } 28 | } 29 | } 30 | 31 | @Test 32 | class ResponseTest { 33 | 34 | fun withHeader() { 35 | val response = Response(200, Headers("foo" to "123", "bar" to "345"), null) 36 | val expected = Response(200, Headers("foo" to "123", "bar" to "345", "baz" to "456"), null) 37 | assertEquals(expected, response.withHeader("baz", "456")) 38 | } 39 | 40 | fun withHeaders() { 41 | val response = Response(200, Headers("foo" to "123", "bar" to "345"), null) 42 | val expected = Response(200, Headers("foo" to "123", "bar" to "345", "baz" to "456", "qux" to "567"), null) 43 | assertEquals(expected, response.withHeaders("baz" to "456", "qux" to "567")) 44 | } 45 | 46 | fun withHeaders2() { 47 | val response = Response(200, Headers("foo" to "123", "bar" to "345"), null) 48 | val expected = Response(200, Headers("foo" to "123", "bar" to "345", "baz" to "456", "qux" to "567"), null) 49 | assertEquals(expected, response.withHeaders(Headers("baz" to "456", "qux" to "567"))) 50 | } 51 | 52 | fun withHeaderMap() { 53 | val response = Response(200, Headers("foo" to "123", "bar" to "345"), null) 54 | val expected = Response(200, Headers("foo" to "123", "bar" to "345", "baz" to "456", "qux" to "567"), null) 55 | assertEquals(expected, response.withHeaders(mapOf("baz" to "456", "qux" to "567"))) 56 | } 57 | } 58 | 59 | @Test 60 | class HeadersTest { 61 | 62 | fun withHeader() { 63 | val headers = Headers("foo" to "123", "bar" to "345") 64 | val expected = Headers("foo" to "123", "bar" to "345", "baz" to "456") 65 | assertEquals(expected, headers.withHeader("baz", "456")) 66 | } 67 | 68 | fun withHeaderOverride() { 69 | val headers = Headers("foo" to "123", "bar" to "345") 70 | val expected = Headers("foo" to "123", "bar" to "456") 71 | assertEquals(expected, headers.withHeader("bar", "456")) 72 | } 73 | 74 | fun withHeaders() { 75 | val headers = Headers("foo" to "123", "bar" to "345") 76 | val expected = Headers("foo" to "123", "bar" to "345", "baz" to "456", "qux" to "567") 77 | assertEquals(expected, headers.withHeaders("baz" to "456", "qux" to "567")) 78 | } 79 | 80 | fun withHeadersOverride() { 81 | val headers = Headers("foo" to "123", "bar" to "345") 82 | val expected = Headers("foo" to "123", "bar" to "456", "baz" to "567") 83 | assertEquals(expected, headers.withHeaders("bar" to "456", "baz" to "567")) 84 | } 85 | 86 | fun plusHeader() { 87 | val headers = Headers("foo" to "123", "bar" to "345") 88 | val expected = Headers("foo" to "123", "bar" to "345", "baz" to "456") 89 | assertEquals(expected, headers + ("baz" to "456")) 90 | } 91 | 92 | fun plusHeaderOverride() { 93 | val headers = Headers("foo" to "123", "bar" to "345") 94 | val expected = Headers("foo" to "123", "bar" to "456") 95 | assertEquals(expected, headers + ("bar" to "456")) 96 | } 97 | 98 | fun plusHeaders() { 99 | val headers1 = Headers("foo" to "123", "bar" to "345") 100 | val headers2 = Headers("baz" to "456", "qux" to "567") 101 | val expected = Headers("foo" to "123", "bar" to "345", "baz" to "456", "qux" to "567") 102 | assertEquals(expected, headers1 + headers2) 103 | } 104 | 105 | fun plusHeadersOverride() { 106 | val headers1 = Headers("foo" to "123", "bar" to "345") 107 | val headers2 = Headers("bar" to "456", "baz" to "567") 108 | val expected = Headers("foo" to "123", "bar" to "456", "baz" to "567") 109 | assertEquals(expected, headers1 + headers2) 110 | } 111 | 112 | fun plusMap() { 113 | val headers = Headers("foo" to "123", "bar" to "345") 114 | val map = mapOf("baz" to "456", "qux" to "567") 115 | val expected = Headers("foo" to "123", "bar" to "345", "baz" to "456", "qux" to "567") 116 | assertEquals(expected, headers + map) 117 | } 118 | 119 | fun plusMapOverride() { 120 | val headers = Headers("foo" to "123", "bar" to "345") 121 | val map = mapOf("bar" to "456", "baz" to "567") 122 | val expected = Headers("foo" to "123", "bar" to "456", "baz" to "567") 123 | assertEquals(expected, headers + map) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /local-server/src/test/kotlin/ws/osiris/localserver/LocalServerTest.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.localserver 2 | 3 | import com.google.gson.Gson 4 | import org.testng.Assert.assertEquals 5 | import org.testng.Assert.assertTrue 6 | import org.testng.annotations.Test 7 | import ws.osiris.core.ComponentsProvider 8 | import ws.osiris.core.RequestContextFactory 9 | import ws.osiris.core.api 10 | 11 | private val components: ComponentsProvider = object : ComponentsProvider {} 12 | 13 | private const val STATIC_DIR = "src/test/static" 14 | 15 | @Test 16 | class LocalServerTest { 17 | 18 | fun get() { 19 | val api = api { 20 | get("/foo") { 21 | "hello, world!" 22 | } 23 | } 24 | LocalHttpTestClient.create(api, components).use { client -> 25 | val response = client.get("/foo") 26 | val body = response.body 27 | assertEquals(response.status, 200) 28 | assertTrue(body == "hello, world!") 29 | } 30 | } 31 | 32 | fun staticFiles() { 33 | val api = api { 34 | staticFiles { 35 | path = "/public" 36 | } 37 | } 38 | LocalHttpTestClient.create(api, components, STATIC_DIR).use { client -> 39 | val response1 = client.get("/public/index.html") 40 | val body1 = response1.body 41 | assertEquals(response1.status, 200) 42 | assertTrue(body1 is String && body1.contains("hello, world!")) 43 | 44 | val response2 = client.get("/public/foo/bar.html") 45 | val body2 = response2.body 46 | assertEquals(response2.status, 200) 47 | assertTrue(body2 is String && body2.contains("hello, bar!")) 48 | } 49 | } 50 | 51 | fun staticFilesIndexFile() { 52 | val api = api { 53 | staticFiles { 54 | path = "/public" 55 | indexFile = "index.html" 56 | } 57 | } 58 | LocalHttpTestClient.create(api, components, STATIC_DIR).use { client -> 59 | val response1 = client.get("/public") 60 | val body1 = response1.body 61 | assertTrue(body1 is String && body1.contains("hello, world!")) 62 | 63 | val response2 = client.get("/public/") 64 | val body2 = response2.body 65 | assertTrue(body2 is String && body2.contains("hello, world!")) 66 | 67 | val response3 = client.get("/public/index.html") 68 | val body3 = response3.body 69 | assertTrue(body3 is String && body3.contains("hello, world!")) 70 | } 71 | } 72 | 73 | fun staticFilesNestedInPath() { 74 | val api = api { 75 | path("/foo") { 76 | staticFiles { 77 | path = "/public" 78 | } 79 | } 80 | } 81 | LocalHttpTestClient.create(api, components, STATIC_DIR).use { client -> 82 | val response1 = client.get("/foo/public/index.html") 83 | val body1 = response1.body 84 | assertEquals(response1.status, 200) 85 | assertTrue(body1 is String && body1.contains("hello, world!")) 86 | 87 | val response2 = client.get("/foo/public/foo/bar.html") 88 | val body2 = response2.body 89 | assertEquals(response2.status, 200) 90 | assertTrue(body2 is String && body2.contains("hello, bar!")) 91 | } 92 | } 93 | 94 | fun staticFilesAtRoot() { 95 | val api = api { 96 | staticFiles { 97 | path = "/" 98 | indexFile = "index.html" 99 | } 100 | get("/hello") { 101 | "get hello" 102 | } 103 | } 104 | LocalHttpTestClient.create(api, components, STATIC_DIR).use { client -> 105 | val response1 = client.get("") 106 | val body1 = response1.body 107 | assertTrue(body1 is String && body1.contains("hello, world!")) 108 | 109 | val response2 = client.get("/") 110 | val body2 = response2.body 111 | assertTrue(body2 is String && body2.contains("hello, world!")) 112 | 113 | val response3 = client.get("/index.html") 114 | val body3 = response3.body 115 | assertTrue(body3 is String && body3.contains("hello, world!")) 116 | 117 | val response4 = client.get("/foo/bar.html") 118 | val body4 = response4.body 119 | assertTrue(body4 is String && body4.contains("hello, bar!")) 120 | 121 | val response5 = client.get("/hello") 122 | val body5 = response5.body 123 | assertTrue(body5 is String && body5.contains("get hello")) 124 | } 125 | } 126 | 127 | fun requestContextFactory() { 128 | val api = api { 129 | get("/hello") { req -> 130 | mapOf("context" to req.context.params) 131 | } 132 | } 133 | val requestContextFactory = RequestContextFactory.fixed("stage" to "dev", "foo" to "bar") 134 | LocalHttpTestClient.create(api, components, requestContextFactory = requestContextFactory).use { client -> 135 | val response = client.get("/hello") 136 | val body = response.body as String 137 | val bodyMap = Gson().fromJson(body, Map::class.java) 138 | assertEquals(bodyMap["context"], mapOf("stage" to "dev", "foo" to "bar")) 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /archetype/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | ws.osiris 7 | osiris-archetype 8 | 2.0.1-SNAPSHOT 9 | maven-archetype 10 | 11 | Osiris Archetype 12 | Maven Archetype for Osiris 13 | https://github.com/cjkent/osiris 14 | 15 | 16 | 17 | The Apache License, Version 2.0 18 | http://www.apache.org/licenses/LICENSE-2.0.txt 19 | 20 | 21 | 22 | 23 | 24 | Chris Kent 25 | cjkent@hotmail.com 26 | https://github.com/cjkent 27 | 28 | 29 | 30 | 31 | scm:git:git://github.com/cjkent/osiris.git 32 | scm:git:ssh://github.com:cjkent/osiris.git 33 | https://github.com/cjkent/osiris/tree/master 34 | 35 | 36 | 37 | 1.9.20 38 | 39 | 40 | 41 | 42 | 43 | org.apache.maven.archetype 44 | archetype-packaging 45 | 3.2.1 46 | 47 | 48 | 54 | 55 | 56 | src/main/resources 57 | true 58 | 59 | META-INF/maven/archetype-metadata.xml 60 | 61 | 62 | 63 | src/main/resources 64 | false 65 | 66 | META-INF/maven/archetype-metadata.xml 67 | 68 | 69 | 70 | 71 | 72 | 73 | maven-archetype-plugin 74 | 3.2.1 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | jcenter 84 | JCenter 85 | https://jcenter.bintray.com/ 86 | 87 | 88 | 89 | 90 | 91 | release 92 | 93 | 94 | 95 | org.jetbrains.dokka 96 | dokka-maven-plugin 97 | ${dokka.version} 98 | 99 | true 100 | 101 | 102 | 103 | org.apache.maven.plugins 104 | maven-source-plugin 105 | 3.3.0 106 | 107 | 108 | attach-sources 109 | package 110 | 111 | jar-no-fork 112 | 113 | 114 | 115 | 116 | 117 | org.apache.maven.plugins 118 | maven-gpg-plugin 119 | 3.2.1 120 | 121 | 122 | sign-artifacts 123 | verify 124 | 125 | sign 126 | 127 | 128 | 129 | 130 | 131 | org.sonatype.plugins 132 | nexus-staging-maven-plugin 133 | 1.6.13 134 | true 135 | 136 | ossrh 137 | https://oss.sonatype.org/ 138 | false 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /maven-plugin/src/main/kotlin/ws/osiris/maven/Maven.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.maven 2 | 3 | import org.apache.maven.artifact.Artifact 4 | import org.apache.maven.plugin.AbstractMojo 5 | import org.apache.maven.plugin.MojoFailureException 6 | import org.apache.maven.plugins.annotations.Component 7 | import org.apache.maven.plugins.annotations.LifecyclePhase 8 | import org.apache.maven.plugins.annotations.Mojo 9 | import org.apache.maven.plugins.annotations.Parameter 10 | import org.apache.maven.plugins.annotations.ResolutionScope 11 | import org.apache.maven.project.MavenProject 12 | import org.slf4j.LoggerFactory 13 | import ws.osiris.aws.validateName 14 | import ws.osiris.awsdeploy.DeployException 15 | import ws.osiris.awsdeploy.DeployableProject 16 | import java.io.FileNotFoundException 17 | import java.nio.file.Files 18 | import java.nio.file.Path 19 | import java.nio.file.Paths 20 | 21 | private val log = LoggerFactory.getLogger("ws.osiris.maven") 22 | 23 | /** 24 | * Parent of the Osiris Mojo classes; contains common configuration parameters used by all subclasses. 25 | */ 26 | abstract class OsirisMojo : AbstractMojo() { 27 | 28 | @Parameter(defaultValue = "\${project.groupId}") 29 | lateinit var rootPackage: String 30 | 31 | @Parameter(property = "osiris.environmentName") 32 | var environmentName: String? = null 33 | 34 | @Parameter(property = "osiris.awsProfile") 35 | var awsProfile: String? = null 36 | 37 | @Parameter(property = "osiris.stackName") 38 | var stackName: String? = null 39 | 40 | @Parameter 41 | var staticFilesDirectory: String? = null 42 | 43 | @Component 44 | private lateinit var mavenProject: MavenProject 45 | 46 | protected val project: DeployableProject get() = 47 | MavenDeployableProject(rootPackage, environmentName, staticFilesDirectory, awsProfile, stackName, mavenProject) 48 | } 49 | 50 | //-------------------------------------------------------------------------------------------------- 51 | 52 | /** 53 | * Mojo defining a goal to generate a CloudFormation template using the API definition and additional configuration. 54 | * 55 | * Generating files in the package phase doesn't feel quite right. But the API must be instantiated to build 56 | * the CloudFormation template. In order to safely instantiate the API we need all the dependencies available. 57 | * The easiest way to do this is to use the project jar which is only built during packaging. 58 | */ 59 | @Mojo( 60 | name = "generate-cloudformation", 61 | // TODO could this be done in COMPILE? would have to build the ApiFactory using project classes instead of the jar 62 | defaultPhase = LifecyclePhase.PACKAGE, 63 | requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME 64 | ) 65 | class GenerateCloudFormationMojo : OsirisMojo() { 66 | 67 | override fun execute() { 68 | try { 69 | project.generateCloudFormation() 70 | } catch (e: DeployException) { 71 | throw MojoFailureException(e.message, e) 72 | } 73 | } 74 | } 75 | 76 | //-------------------------------------------------------------------------------------------------- 77 | 78 | /** 79 | * Mojo defining the deployment goal; deploys an API and lambda function to AWS. 80 | */ 81 | @Mojo( 82 | name = "deploy", 83 | defaultPhase = LifecyclePhase.DEPLOY, 84 | requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME 85 | ) 86 | class DeployMojo : OsirisMojo() { 87 | 88 | override fun execute() { 89 | try { 90 | project.deploy() 91 | } catch (e: DeployException) { 92 | throw MojoFailureException(e.message, e) 93 | } 94 | } 95 | } 96 | 97 | @Mojo( 98 | name = "open", 99 | requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME 100 | ) 101 | class OpenMojo : OsirisMojo() { 102 | 103 | @Parameter(property = "stage", required = true) 104 | lateinit var stage: String 105 | 106 | @Parameter(property = "endpoint", required = true) 107 | lateinit var endpoint: String 108 | 109 | override fun execute() { 110 | try { 111 | project.openBrowser(stage, endpoint) 112 | } catch (e: DeployException) { 113 | throw MojoFailureException(e.message, e) 114 | } 115 | } 116 | } 117 | 118 | //-------------------------------------------------------------------------------------------------- 119 | 120 | class MavenDeployableProject( 121 | override val rootPackage: String, 122 | override val environmentName: String?, 123 | override val staticFilesDirectory: String?, 124 | override val awsProfile: String?, 125 | override val stackName: String?, 126 | private val project: MavenProject 127 | ) : DeployableProject { 128 | override val name: String = project.artifactId 129 | override val version: String = project.version 130 | override val buildDir: Path = Paths.get(project.build.directory) 131 | override val zipBuildDir: Path = buildDir 132 | override val sourceDir: Path = Paths.get(project.build.sourceDirectory).parent 133 | override val runtimeClasspath: List get() = project.artifacts.map { (it as Artifact).file.toPath() }.toList() 134 | override val projectJar: Path get() { 135 | return if (project.artifact.file != null) { 136 | val path = project.artifact.file.toPath() 137 | log.debug("project.artifact.file != null, exists = {}", Files.exists(path)) 138 | path 139 | } else { 140 | // This is a horrible hack. project.artifact.file is null in some Mojos and not in others 141 | val jarPath = buildDir.resolve(project.artifact.artifactId + "-" + project.artifact.version + ".jar") 142 | if (Files.exists(jarPath)) { 143 | log.debug("project.artifact.file == null, path {} exists", jarPath.toAbsolutePath()) 144 | return jarPath 145 | } else { 146 | log.debug("project.artifact.file == null, path {} does not exist", jarPath.toAbsolutePath()) 147 | throw FileNotFoundException("Project jar not found, run mvn package") 148 | } 149 | } 150 | } 151 | 152 | init { 153 | validateName(environmentName) 154 | validateName(stackName) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /core/src/test/kotlin/ws/osiris/core/MatchTest.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.core 2 | 3 | import org.testng.annotations.Test 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertNull 6 | 7 | @Test 8 | class MatchTest { 9 | 10 | class Components : ComponentsProvider 11 | 12 | private val req = Request(HttpMethod.GET, "notUsed", Params(), Params(), Params(), Params(), null) 13 | private val comps = Components() 14 | 15 | fun fixedRoute() { 16 | val handler: RequestHandler = { req -> req.responseBuilder().build("1") } 17 | val route = LambdaRoute(HttpMethod.GET, "/foo", handler) 18 | val node = RouteNode.create(route) 19 | assertNull(node.match(HttpMethod.GET, "/")) 20 | assertNull(node.match(HttpMethod.GET, "/bar")) 21 | assertNull(node.match(HttpMethod.POST, "/foo")) 22 | assertEquals("1", node.match(HttpMethod.GET, "/foo")!!.handler(comps, req).body) 23 | } 24 | 25 | fun fixedRouteMultipleMethods() { 26 | val handler1: RequestHandler = { req -> req.responseBuilder().build("1") } 27 | val handler2: RequestHandler = { req -> req.responseBuilder().build("2") } 28 | val route1 = LambdaRoute(HttpMethod.GET, "/foo", handler1) 29 | val route2 = LambdaRoute(HttpMethod.POST, "/foo", handler2) 30 | val node = RouteNode.create(route1, route2) 31 | assertNull(node.match(HttpMethod.GET, "/")) 32 | assertEquals("1", node.match(HttpMethod.GET, "/foo")!!.handler(comps, req).body) 33 | assertEquals("2", node.match(HttpMethod.POST, "/foo")!!.handler(comps, req).body) 34 | } 35 | 36 | fun multipleFixedRoutes() { 37 | val handler1: RequestHandler = { req -> req.responseBuilder().build("1") } 38 | val handler2: RequestHandler = { req -> req.responseBuilder().build("2") } 39 | val route1 = LambdaRoute(HttpMethod.GET, "/foo", handler1) 40 | val route2 = LambdaRoute(HttpMethod.POST, "/bar/baz", handler2) 41 | val node = RouteNode.create(route1, route2) 42 | assertNull(node.match(HttpMethod.GET, "/")) 43 | assertNull(node.match(HttpMethod.GET, "/foo/baz")) 44 | assertEquals("1", node.match(HttpMethod.GET, "/foo")!!.handler(comps, req).body) 45 | assertEquals("2", node.match(HttpMethod.POST, "/bar/baz")!!.handler(comps, req).body) 46 | } 47 | 48 | fun variableRoute() { 49 | val handler: RequestHandler = { req -> req.responseBuilder().build("1") } 50 | val route = LambdaRoute(HttpMethod.GET, "/{foo}", handler) 51 | val node = RouteNode.create(route) 52 | assertNull(node.match(HttpMethod.GET, "/")) 53 | assertNull(node.match(HttpMethod.POST, "/bar")) 54 | val match = node.match(HttpMethod.GET, "/bar") 55 | val response = match!!.handler(comps, req) 56 | assertEquals("1", response.body) 57 | assertEquals(mapOf("foo" to "bar"), match.vars) 58 | } 59 | 60 | fun variableRouteMultipleMethods() { 61 | val handler1: RequestHandler = { req -> req.responseBuilder().build("1") } 62 | val handler2: RequestHandler = { req -> req.responseBuilder().build("2") } 63 | val route1 = LambdaRoute(HttpMethod.GET, "/{foo}", handler1) 64 | val route2 = LambdaRoute(HttpMethod.POST, "/{foo}", handler2) 65 | val node = RouteNode.create(route1, route2) 66 | assertNull(node.match(HttpMethod.GET, "/")) 67 | val match1 = node.match(HttpMethod.GET, "/bar") 68 | val response1 = match1!!.handler(comps, req) 69 | assertEquals("1", response1.body) 70 | assertEquals(mapOf("foo" to "bar"), match1.vars) 71 | val match2 = node.match(HttpMethod.POST, "/bar") 72 | val response2 = match2!!.handler(comps, req) 73 | assertEquals("2", response2.body) 74 | assertEquals(mapOf("foo" to "bar"), match2.vars) 75 | } 76 | 77 | fun variableRouteMultipleVariables() { 78 | val handler: RequestHandler = { req -> req.responseBuilder().build("1") } 79 | val route = LambdaRoute(HttpMethod.GET, "/{foo}/bar/{baz}", handler) 80 | val node = RouteNode.create(route) 81 | val match = node.match(HttpMethod.GET, "/abc/bar/def") 82 | val response = match!!.handler(comps, req) 83 | assertEquals(mapOf("foo" to "abc", "baz" to "def"), match.vars) 84 | assertEquals("1", response.body) 85 | } 86 | 87 | fun multipleVariableRoutes() { 88 | val handler1: RequestHandler = { req -> req.responseBuilder().build("1") } 89 | val handler2: RequestHandler = { req -> req.responseBuilder().build("2") } 90 | val route1 = LambdaRoute(HttpMethod.GET, "/{foo}", handler1) 91 | val route2 = LambdaRoute(HttpMethod.POST, "/{foo}/baz", handler2) 92 | val node = RouteNode.create(route1, route2) 93 | val match1 = node.match(HttpMethod.GET, "/bar") 94 | val response1 = match1!!.handler(comps, req) 95 | assertEquals("1", response1.body) 96 | assertEquals(mapOf("foo" to "bar"), match1.vars) 97 | val match2 = node.match(HttpMethod.POST, "/bar/baz") 98 | val response2 = match2!!.handler(comps, req) 99 | assertEquals("2", response2.body) 100 | assertEquals(mapOf("foo" to "bar"), match2.vars) 101 | assertNull(node.match(HttpMethod.POST, "/bar/qux")) 102 | } 103 | 104 | fun fixedTakesPriority() { 105 | val handler1: RequestHandler = { req -> req.responseBuilder().build("1") } 106 | val handler2: RequestHandler = { req -> req.responseBuilder().build("2") } 107 | val route1 = LambdaRoute(HttpMethod.GET, "/{foo}", handler1) 108 | val route2 = LambdaRoute(HttpMethod.GET, "/foo", handler2) 109 | val node = RouteNode.create(route1, route2) 110 | assertEquals("2", node.match(HttpMethod.GET, "/foo")!!.handler(comps, req).body) 111 | } 112 | 113 | fun handlerAtRoot() { 114 | val handler: RequestHandler = { req -> req.responseBuilder().build("1") } 115 | val route = LambdaRoute(HttpMethod.GET, "/", handler) 116 | val node = RouteNode.create(route) 117 | assertNull(node.match(HttpMethod.GET, "/foo")) 118 | assertEquals("1", node.match(HttpMethod.GET, "/")!!.handler(comps, req).body) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /core/src/test/kotlin/ws/osiris/core/TestClient.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.core 2 | 3 | import java.nio.file.Files 4 | import java.nio.file.Path 5 | 6 | /** 7 | * A simple client for testing APIs. 8 | * 9 | * This can be implemented to hit the local server, an API deployed to API Gateway or a test server 10 | * running in memory. 11 | */ 12 | interface TestClient { 13 | fun get(path: String, headers: Map = mapOf()): TestResponse 14 | // TODO maybe make the body a class (RequestBody?) that can contain the contents and the base64 flag 15 | fun post(path: String, body: String, headers: Map = mapOf()): TestResponse 16 | fun options(path: String, headers: Map = mapOf()): TestResponse 17 | 18 | } 19 | 20 | data class TestResponse(val status: Int, val headers: Headers, val body: Any?) 21 | 22 | /** 23 | * Test client that dispatches requests to an API in memory without going via HTTP. 24 | */ 25 | class InMemoryTestClient private constructor( 26 | api: Api, 27 | private val components: T, 28 | private val staticFileDirectory: Path? = null, 29 | private val requestContextFactory: RequestContextFactory = RequestContextFactory.empty() 30 | ) : TestClient { 31 | 32 | private val root: RouteNode = RouteNode.create(api) 33 | 34 | override fun get(path: String, headers: Map): TestResponse { 35 | val splitPath = path.split('?') 36 | val queryParams = when (splitPath.size) { 37 | 1 -> Params() 38 | 2 -> Params.fromQueryString(splitPath[1]) 39 | else -> throw IllegalArgumentException("Unexpected path format - found two question marks") 40 | } 41 | val resource = splitPath[0] 42 | val routeMatch = root.match(HttpMethod.GET, resource) 43 | return if (routeMatch == null) { 44 | // if there's no match, check if it's a static path and serve the static file 45 | handleStaticRequest(resource) 46 | } else { 47 | handleRestRequest(HttpMethod.GET, resource, headers, queryParams, routeMatch) 48 | } 49 | } 50 | 51 | override fun post(path: String, body: String, headers: Map): TestResponse { 52 | val headerParams = Params(headers) 53 | val (handler, vars) = root.match(HttpMethod.POST, path) ?: throw DataNotFoundException() 54 | val pathParams = Params(vars) 55 | val context = requestContextFactory.createContext(HttpMethod.POST, path, headerParams, Params(), pathParams, body) 56 | val request = Request( 57 | method = HttpMethod.POST, 58 | path = path, 59 | headers = headerParams, 60 | queryParams = Params(), 61 | pathParams = pathParams, 62 | context = context, 63 | body = body 64 | ) 65 | val (status, responseHeaders, responseBody) = handler(components, request) 66 | return TestResponse(status, responseHeaders, responseBody) 67 | } 68 | 69 | override fun options(path: String, headers: Map): TestResponse { 70 | val routeMatch = root.match(HttpMethod.OPTIONS, path) ?: throw DataNotFoundException() 71 | return handleRestRequest(HttpMethod.OPTIONS, path, headers, Params(), routeMatch) 72 | } 73 | 74 | private fun handleRestRequest( 75 | method: HttpMethod, 76 | resource: String, 77 | headers: Map, 78 | queryParams: Params, 79 | routeMatch: RouteMatch 80 | ): TestResponse { 81 | 82 | val headerParams = Params(headers) 83 | val pathParams = Params(routeMatch.vars) 84 | val context = requestContextFactory.createContext(method, resource, headerParams, Params(), pathParams, null) 85 | val request = Request( 86 | method = method, 87 | path = resource, 88 | headers = Params(headers), 89 | queryParams = queryParams, 90 | pathParams = pathParams, 91 | context = context 92 | ) 93 | val (status, responseHeaders, body) = routeMatch.handler(components, request) 94 | return TestResponse(status, responseHeaders, body) 95 | } 96 | 97 | private fun handleStaticRequest(resource: String): TestResponse { 98 | 99 | fun staticMatch(node: RouteNode, requestPath: RequestPath): StaticRouteMatch? = when { 100 | node is StaticRouteNode -> StaticRouteMatch(node, requestPath.segments.joinToString("/")) 101 | requestPath.isEmpty() -> null 102 | else -> node.fixedChildren[requestPath.head()]?.let { staticMatch(it, requestPath.tail()) } 103 | } 104 | 105 | val staticMatch = staticMatch(root, RequestPath(resource)) ?: throw DataNotFoundException() 106 | if (staticFileDirectory == null) { 107 | throw IllegalStateException("Received request for static resource " + "$resource but " + 108 | "no static directory is configured") 109 | } 110 | val file = if (staticMatch.path.isEmpty()) { 111 | // path points directly to the static endpoint in which case use the index file if there is one 112 | val indexFile = staticMatch.node.indexFile ?: throw DataNotFoundException() 113 | staticFileDirectory.resolve(indexFile) 114 | } else { 115 | staticFileDirectory.resolve(staticMatch.path) 116 | } 117 | val fileBytes = Files.readAllBytes(file) 118 | return TestResponse(200, Headers(), String(fileBytes, Charsets.UTF_8)) 119 | } 120 | 121 | companion object { 122 | 123 | /** Returns a client for a simple API that doesn't use any components in its handlers. */ 124 | fun create( 125 | api: Api, 126 | staticFilesDir: Path? = null, 127 | requestContextFactory: RequestContextFactory = RequestContextFactory.empty() 128 | ): InMemoryTestClient = 129 | InMemoryTestClient(api, object : ComponentsProvider {}, staticFilesDir, requestContextFactory) 130 | 131 | /** Returns a client for an API that uses components in its handlers. */ 132 | fun create( 133 | components: T, 134 | api: Api, 135 | staticFilesDir: Path? = null, 136 | requestContextFactory: RequestContextFactory = RequestContextFactory.empty() 137 | ): InMemoryTestClient = InMemoryTestClient(api, components, staticFilesDir, requestContextFactory) 138 | } 139 | 140 | private inner class StaticRouteMatch(val node: StaticRouteNode, val path: String) 141 | } 142 | -------------------------------------------------------------------------------- /core/src/test/kotlin/ws/osiris/core/ModelTest.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.core 2 | 3 | import org.testng.annotations.Test 4 | import kotlin.test.assertEquals 5 | import kotlin.test.assertNotNull 6 | import kotlin.test.assertNull 7 | import kotlin.test.assertTrue 8 | 9 | @Test 10 | class ModelTest { 11 | 12 | class Components : ComponentsProvider 13 | 14 | private val req = Request(HttpMethod.GET, "notUsed", Params(), Params(), Params(), Params(), null) 15 | private val comps = Components() 16 | 17 | fun createSimpleSubRoute() { 18 | val handler: RequestHandler = { req -> req.responseBuilder().build("") } 19 | val route = LambdaRoute(HttpMethod.GET, "/foo/bar", handler) 20 | val subRoute = SubRoute(route) 21 | assertEquals(subRoute.segments, listOf(FixedSegment("foo"), FixedSegment("bar"))) 22 | } 23 | 24 | fun createVariableSubRoute() { 25 | val handler: RequestHandler = { req -> req.responseBuilder().build("") } 26 | val route = LambdaRoute(HttpMethod.GET, "/foo/{bar}/baz", handler) 27 | val subRoute = SubRoute(route) 28 | assertEquals(subRoute.segments, listOf(FixedSegment("foo"), VariableSegment("bar"), FixedSegment("baz"))) 29 | } 30 | 31 | fun createSimpleRouteNode() { 32 | val handler1: RequestHandler = { req -> req.responseBuilder().build("1") } 33 | val handler2: RequestHandler = { req -> req.responseBuilder().build("2") } 34 | val route1 = LambdaRoute(HttpMethod.GET, "/foo", handler1) 35 | val route2 = LambdaRoute(HttpMethod.POST, "/foo/bar", handler2) 36 | val rootNode = RouteNode.create(route1, route2) 37 | 38 | assertEquals("", rootNode.name) 39 | assertNull(rootNode.variableChild) 40 | assertEquals(rootNode.handlers.size, 0) 41 | 42 | assertEquals(setOf("foo"), rootNode.fixedChildren.keys) 43 | val fooNode = rootNode.fixedChildren["foo"]!! 44 | assertEquals(setOf(HttpMethod.GET), fooNode.handlers.keys) 45 | val (fooHandler, fooAuth) = fooNode.handlers[HttpMethod.GET]!! 46 | assertEquals("1", fooHandler(comps, req).body) 47 | assertEquals(NoAuth, fooAuth) 48 | 49 | assertEquals(setOf("bar"), fooNode.fixedChildren.keys) 50 | val barNode = fooNode.fixedChildren["bar"]!! 51 | assertEquals(setOf(HttpMethod.POST), barNode.handlers.keys) 52 | val (barHandler, barAuth) = barNode.handlers[HttpMethod.POST]!! 53 | assertEquals("2", barHandler(comps, req).body) 54 | assertEquals(NoAuth, barAuth) 55 | } 56 | 57 | fun createVariableRouteNode() { 58 | val handler: RequestHandler = { req -> req.responseBuilder().build("1") } 59 | val route = LambdaRoute(HttpMethod.POST, "/{bar}", handler) 60 | val rootNode = RouteNode.create(route) 61 | assertTrue(rootNode.fixedChildren.isEmpty()) 62 | assertEquals("bar", rootNode.variableChild?.name) 63 | assertEquals("1", rootNode.variableChild?.handlers?.get(HttpMethod.POST)!!.first(comps, req).body) 64 | } 65 | 66 | fun createRouteNodeWithDuplicateRoutesDifferentMethods() { 67 | val handler1: RequestHandler = { req -> req.responseBuilder().build("") } 68 | val handler2: RequestHandler = { req -> req.responseBuilder().build("") } 69 | val route1 = LambdaRoute(HttpMethod.GET, "/foo", handler1) 70 | val route2 = LambdaRoute(HttpMethod.POST, "/foo", handler2) 71 | RouteNode.create(route1, route2) 72 | } 73 | 74 | fun createRouteNodeWithDuplicateVariableRoutesDifferentMethods() { 75 | val handler1: RequestHandler = { req -> req.responseBuilder().build("1") } 76 | val handler2: RequestHandler = { req -> req.responseBuilder().build("2") } 77 | val route1 = LambdaRoute(HttpMethod.GET, "/{foo}", handler1) 78 | val route2 = LambdaRoute(HttpMethod.POST, "/{foo}", handler2) 79 | val rootNode = RouteNode.create(route1, route2) 80 | assertNotNull(rootNode.variableChild) 81 | val variableChild = rootNode.variableChild!! 82 | assertEquals("foo", variableChild.name) 83 | assertEquals(setOf(HttpMethod.GET, HttpMethod.POST), variableChild.handlers.keys) 84 | assertEquals("1", variableChild.handlers[HttpMethod.GET]!!.first(comps, req).body) 85 | assertEquals("2", variableChild.handlers[HttpMethod.POST]!!.first(comps, req).body) 86 | } 87 | 88 | @Test( 89 | expectedExceptions = arrayOf(IllegalArgumentException::class), 90 | expectedExceptionsMessageRegExp = "Multiple routes with the same HTTP method.*") 91 | fun createRouteNodeWithDuplicateRoutes() { 92 | val handler1: RequestHandler = { req -> req.responseBuilder().build("") } 93 | val handler2: RequestHandler = { req -> req.responseBuilder().build("") } 94 | val route1 = LambdaRoute(HttpMethod.GET, "/foo", handler1) 95 | val route2 = LambdaRoute(HttpMethod.GET, "/foo", handler2) 96 | RouteNode.create(route1, route2) 97 | } 98 | 99 | @Test( 100 | expectedExceptions = arrayOf(IllegalArgumentException::class), 101 | expectedExceptionsMessageRegExp = "Routes found with clashing variable names.*") 102 | fun createRouteNodeWithNonMatchingVariableNames() { 103 | val handler: RequestHandler = { req -> req.responseBuilder().build("") } 104 | val route1 = LambdaRoute(HttpMethod.GET, "/{foo}/bar", handler) 105 | val route2 = LambdaRoute(HttpMethod.GET, "/{bar}", handler) 106 | RouteNode.create(route1, route2) 107 | } 108 | 109 | fun createMultipleVariableRouteNodes() { 110 | val handler: RequestHandler = { req -> req.responseBuilder().build("") } 111 | val route1 = LambdaRoute(HttpMethod.GET, "/{foo}/bar", handler) 112 | val route2 = LambdaRoute(HttpMethod.GET, "/{foo}", handler) 113 | RouteNode.create(route1, route2) 114 | } 115 | 116 | fun createRootRouteNode() { 117 | val handler: RequestHandler = { req -> req.responseBuilder().build("1") } 118 | val route = LambdaRoute(HttpMethod.GET, "/", handler) 119 | val rootNode = RouteNode.create(route) 120 | assertEquals("", rootNode.name) 121 | assertNull(rootNode.variableChild) 122 | assertEquals(rootNode.handlers.size, 1) 123 | assertEquals(setOf(HttpMethod.GET), rootNode.handlers.keys) 124 | val (rootHandler, rootAuth) = rootNode.handlers[HttpMethod.GET]!! 125 | assertTrue(rootNode is FixedRouteNode) 126 | assertEquals("", rootNode.name) 127 | assertEquals("1", rootHandler(comps, req).body) 128 | assertEquals(NoAuth, rootAuth) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /aws/src/test/kotlin/ws/osiris/aws/RequestTest.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.aws 2 | 3 | import com.amazonaws.services.lambda.runtime.ClientContext 4 | import com.amazonaws.services.lambda.runtime.CognitoIdentity 5 | import com.amazonaws.services.lambda.runtime.Context 6 | import com.amazonaws.services.lambda.runtime.LambdaLogger 7 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 8 | import com.fasterxml.jackson.databind.DeserializationFeature 9 | import com.fasterxml.jackson.databind.ObjectMapper 10 | import org.intellij.lang.annotations.Language 11 | 12 | import org.testng.annotations.Test 13 | import kotlin.test.assertEquals 14 | import kotlin.test.assertTrue 15 | 16 | @Test 17 | class RequestTest { 18 | 19 | /** 20 | * Tests deserialization of the request JSON into a [ws.osiris.core.Request]. 21 | */ 22 | fun deserializeInput() { 23 | // plain Jackson ObjectMapper (without the Kotlin module). it's what AWS uses 24 | val objectMapper = ObjectMapper() 25 | objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) 26 | val requestEvent = objectMapper.readValue(requestJson, APIGatewayProxyRequestEvent::class.java) 27 | val request = buildRequest(requestEvent, TestContext()) 28 | assertEquals("/foo", request.path) 29 | assertEquals("GET", request.method.name) 30 | assertEquals(mapOf("foo" to "bar", "baz" to "qux"), request.queryParams.params) 31 | assertTrue(request.body is ByteArray) 32 | assertEquals("the body text", String(request.requireBinaryBody())) 33 | val stageVars = mapOf("FOO" to "123", "BAR" to "ABC") 34 | assertEquals(stageVars, request.stageVariables) 35 | assertEquals("dev", request.stageName) 36 | } 37 | 38 | /** 39 | * A sample of the JSON passed to the lambda by API Gateway. 40 | */ 41 | @Language("json") 42 | private val requestJson = """ 43 | { 44 | "resource": "/foo", 45 | "path": "/base/foo", 46 | "httpMethod": "GET", 47 | "headers": { 48 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", 49 | "Accept-Encoding": "gzip, deflate, br", 50 | "Accept-Language": "en-GB,en-US;q=0.9,en;q=0.8", 51 | "CloudFront-Forwarded-Proto": "https", 52 | "CloudFront-Is-Desktop-Viewer": "true", 53 | "CloudFront-Is-Mobile-Viewer": "false", 54 | "CloudFront-Is-SmartTV-Viewer": "false", 55 | "CloudFront-Is-Tablet-Viewer": "false", 56 | "CloudFront-Viewer-Country": "GB", 57 | "dnt": "1", 58 | "Host": "f2nzkw5aga.execute-api.eu-west-1.amazonaws.com", 59 | "Referer": "https://eu-west-1.console.aws.amazon.com/apigateway/home?region=eu-west-1", 60 | "upgrade-insecure-requests": "1", 61 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36 OPR/49.0.2725.64", 62 | "Via": "2.0 87ce1a2818e8b605bc0c86bdab0851bf.cloudfront.net (CloudFront)", 63 | "X-Amz-Cf-Id": "T_1lBQv4Ru-At46EDRWeU0Rsx5u-VBfrkp4ELwSOT4XEyjqSVlcmhQ==", 64 | "X-Amzn-Trace-Id": "Root=1-5a5d283f-0e48e9917e6bff831f424b82", 65 | "X-Forwarded-For": "146.199.137.70, 52.46.38.16", 66 | "X-Forwarded-Port": "443", 67 | "X-Forwarded-Proto": "https" 68 | }, 69 | "queryStringParameters": { 70 | "foo": "bar", 71 | "baz": "qux" 72 | }, 73 | "pathParameters": null, 74 | "stageVariables": { 75 | "FOO": "123", 76 | "BAR": "ABC" 77 | }, 78 | "requestContext": { 79 | "requestTime": "15/Jan/2018:22:16:31 +0000", 80 | "path": "/dev", 81 | "accountId": "12345678", 82 | "protocol": "HTTP/1.1", 83 | "resourceId": "a8nhuga3f9", 84 | "stage": "dev", 85 | "requestTimeEpoch": 1516054591536, 86 | "requestId": "bd63ecc1-fa41-11e7-b81a-e950967c4905", 87 | "identity": { 88 | "cognitoIdentityPoolId": null, 89 | "accountId": null, 90 | "cognitoIdentityId": null, 91 | "caller": null, 92 | "sourceIp": "146.199.137.70", 93 | "accessKey": null, 94 | "cognitoAuthenticationType": null, 95 | "cognitoAuthenticationProvider": null, 96 | "userArn": null, 97 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36 OPR/49.0.2725.64", 98 | "user": null 99 | }, 100 | "resourcePath": "/", 101 | "httpMethod": "GET", 102 | "apiId": "f2nzkw5aga" 103 | }, 104 | "body": "dGhlIGJvZHkgdGV4dA==", 105 | "isBase64Encoded": true 106 | } 107 | """.trimIndent() 108 | } 109 | 110 | private class TestContext : Context { 111 | 112 | override fun getAwsRequestId(): String { 113 | throw UnsupportedOperationException("getAwsRequestId not implemented") 114 | } 115 | 116 | override fun getLogStreamName(): String { 117 | throw UnsupportedOperationException("getLogStreamName not implemented") 118 | } 119 | 120 | override fun getClientContext(): ClientContext { 121 | throw UnsupportedOperationException("getClientContext not implemented") 122 | } 123 | 124 | override fun getFunctionName(): String { 125 | throw UnsupportedOperationException("getFunctionName not implemented") 126 | } 127 | 128 | override fun getRemainingTimeInMillis(): Int { 129 | throw UnsupportedOperationException("getRemainingTimeInMillis not implemented") 130 | } 131 | 132 | override fun getLogger(): LambdaLogger { 133 | throw UnsupportedOperationException("getLogger not implemented") 134 | } 135 | 136 | override fun getInvokedFunctionArn(): String { 137 | throw UnsupportedOperationException("getInvokedFunctionArn not implemented") 138 | } 139 | 140 | override fun getMemoryLimitInMB(): Int { 141 | throw UnsupportedOperationException("getMemoryLimitInMB not implemented") 142 | } 143 | 144 | override fun getLogGroupName(): String { 145 | throw UnsupportedOperationException("getLogGroupName not implemented") 146 | } 147 | 148 | override fun getFunctionVersion(): String { 149 | throw UnsupportedOperationException("getFunctionVersion not implemented") 150 | } 151 | 152 | override fun getIdentity(): CognitoIdentity { 153 | throw UnsupportedOperationException("getIdentity not implemented") 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/ws/osiris/gradle/DeployPlugin.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.gradle 2 | 3 | import org.gradle.api.DefaultTask 4 | import org.gradle.api.Plugin 5 | import org.gradle.api.Project 6 | import org.gradle.api.tasks.TaskAction 7 | import org.gradle.api.tasks.TaskExecutionException 8 | import org.gradle.api.tasks.bundling.Zip 9 | import ws.osiris.aws.validateName 10 | import ws.osiris.awsdeploy.DeployableProject 11 | import java.nio.file.Path 12 | import kotlin.reflect.KClass 13 | 14 | /** The version Gradle gives to a project if no version is specified. */ 15 | private const val NO_VERSION: String = "unspecified" 16 | 17 | /** 18 | * Gradle plugin that handles building and deploying Osiris projects. 19 | * 20 | * Adds tasks to: 21 | * * Build a jar containing the application and all its dependencies 22 | * * Generate a CloudFormation template that defines the API and lambda 23 | * * Deploy the application to AWS 24 | * * Open an endpoint from a deployed stage in the system default browser 25 | */ 26 | class OsirisDeployPlugin : Plugin { 27 | 28 | override fun apply(project: Project) { 29 | val extension = project.extensions.create("osiris", OsirisDeployPluginExtension::class.java) 30 | val deployableProject = GradleDeployableProject(project, extension) 31 | val zipTask = project.tasks.create("zip", Zip::class.java) 32 | val deployTask = createTask(project, deployableProject, extension, "deploy", OsirisDeployTask::class) 33 | val generateTemplateTask = createTask( 34 | project, 35 | deployableProject, 36 | extension, 37 | "generateCloudFormation", 38 | OsirisGenerateCloudFormationTask::class 39 | ) 40 | createTask(project, deployableProject, extension, "open", OsirisOpenTask::class) 41 | project.afterEvaluate { 42 | for (task in project.getTasksByName("assemble", false)) zipTask.dependsOn(task) 43 | generateTemplateTask.dependsOn(zipTask) 44 | deployTask.dependsOn(generateTemplateTask) 45 | val jarPaths = mutableListOf() 46 | jarPaths.addAll(deployableProject.runtimeClasspath) 47 | jarPaths.add(deployableProject.projectJar) 48 | zipTask.from(jarPaths) 49 | zipTask.into("lib") 50 | val versionStr = if (project.version == NO_VERSION) "" else "-${project.version}" 51 | zipTask.archiveName = "${project.name}$versionStr-dist.zip" 52 | } 53 | } 54 | 55 | private fun createTask( 56 | project: Project, 57 | deployableProject: DeployableProject, 58 | extension: OsirisDeployPluginExtension, 59 | name: String, 60 | type: KClass 61 | ): T = project.tasks.create(name, type.java).apply { 62 | this.deployableProject = deployableProject 63 | this.extension = extension 64 | } 65 | } 66 | 67 | /** 68 | * Allows the plugins to be configured using the Gradle DSL. 69 | * 70 | * osiris { 71 | * rootPackage = "com.example.application" 72 | * staticFilesDirectory = "/some/directory" 73 | * environmentName = "dev" 74 | * awsProfile = "dev-account" 75 | * } 76 | */ 77 | open class OsirisDeployPluginExtension( 78 | var rootPackage: String? = null, 79 | var staticFilesDirectory: String? = null, 80 | var environmentName: String? = null, 81 | var awsProfile: String? = null, 82 | var stackName: String? = null 83 | ) 84 | 85 | /** 86 | * Base class for tasks. 87 | */ 88 | abstract class OsirisTask : DefaultTask() { 89 | 90 | internal lateinit var deployableProject: DeployableProject 91 | internal lateinit var extension: OsirisDeployPluginExtension 92 | } 93 | 94 | /** 95 | * Task to generate the CloudFormation template. 96 | */ 97 | open class OsirisGenerateCloudFormationTask : OsirisTask() { 98 | 99 | @TaskAction 100 | fun generate() { 101 | try { 102 | deployableProject.generateCloudFormation() 103 | } catch (e: Exception) { 104 | throw TaskExecutionException(this, e) 105 | } 106 | } 107 | } 108 | 109 | 110 | /** 111 | * Task to upload the static files to S3, the jar to the S3 code bucket and then deploy the CloudFormation stack. 112 | */ 113 | open class OsirisDeployTask : OsirisTask() { 114 | 115 | @TaskAction 116 | fun deploy() { 117 | try { 118 | val stageUrls = deployableProject.deploy() 119 | for ((stage, url) in stageUrls) { 120 | logger.lifecycle("Deployed to stage '$stage' at $url") 121 | } 122 | } catch (e: Exception) { 123 | throw TaskExecutionException(this, e) 124 | } 125 | } 126 | } 127 | 128 | /** 129 | * Task to open an endpoint from a stage in the system default browser. 130 | */ 131 | open class OsirisOpenTask : OsirisTask() { 132 | 133 | @TaskAction 134 | fun deploy() { 135 | try { 136 | if (!project.hasProperty("stage")) { 137 | throw IllegalStateException("Please specify the stage using '-P=...', for example: '-Pstage=dev'") 138 | } 139 | if (!project.hasProperty("endpoint")) { 140 | throw IllegalStateException("Please specify the endpoint using '-P=...', for example: '-Pendpoint=/foo/bar'") 141 | } 142 | val stage = project.property("stage") as String 143 | val endpoint = project.property("endpoint") as String 144 | deployableProject.openBrowser(stage, endpoint) 145 | } catch (e: Exception) { 146 | throw TaskExecutionException(this, e) 147 | } 148 | } 149 | } 150 | 151 | /** 152 | * Integrates with the deployment logic in the AWS deployment module. 153 | */ 154 | private class GradleDeployableProject( 155 | private val project: Project, 156 | private val extension: OsirisDeployPluginExtension 157 | ) : DeployableProject { 158 | 159 | override val sourceDir: Path = project.projectDir.toPath().resolve("src/main") 160 | override val name: String = project.name 161 | override val buildDir: Path = project.buildDir.toPath() 162 | override val zipBuildDir: Path = buildDir.resolve("distributions") 163 | override val rootPackage: String get() = extension.rootPackage ?: throw IllegalStateException("rootPackage required") 164 | override val version: String? = if (project.version == NO_VERSION) null else project.version.toString() 165 | override val environmentName: String? get() = extension.environmentName 166 | override val staticFilesDirectory: String? get() = extension.staticFilesDirectory 167 | override val awsProfile: String? get() = validateName(extension.awsProfile) 168 | override val stackName: String? get() = validateName(extension.stackName) 169 | override val projectJar: Path 170 | get() = project.configurations.getByName("runtime").allArtifacts.files.singleFile.toPath() 171 | override val runtimeClasspath: List 172 | get() = project.configurations.getByName("runtimeClasspath").resolvedConfiguration.resolvedArtifacts.map { it.file.toPath() } 173 | } 174 | -------------------------------------------------------------------------------- /.idea/codeStyleSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 146 | 148 | -------------------------------------------------------------------------------- /aws-deploy/src/test/kotlin/ws/osiris/awsdeploy/cloudformation/ResourceNodeTest.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.awsdeploy.cloudformation 2 | 3 | import org.testng.annotations.Test 4 | import ws.osiris.aws.ApplicationConfig 5 | import ws.osiris.aws.Stage 6 | import ws.osiris.core.ComponentsProvider 7 | import ws.osiris.core.api 8 | import kotlin.test.assertEquals 9 | 10 | @Test 11 | class ResourceTemplateTest { 12 | 13 | private val config = ApplicationConfig( 14 | applicationName = "notUsed", 15 | stages = listOf( 16 | Stage( 17 | name = "test", 18 | deployOnUpdate = false 19 | ) 20 | ) 21 | ) 22 | 23 | private val api = api { 24 | get("/") {} // 1 resource (it's root so there's no resource for the REST resource, only for the method) 25 | post("/") {} // 1 (as above) 26 | get("/foo") {} // 2 27 | get("/foo/bar") {} // 2 28 | get("/baz") {} // 2 29 | get("/baz/bar") {} // 2 30 | post("/baz/bar") {} // 1 (same resource as above, only adds a method) 31 | get("/qux") {} // 2 32 | get("/blah") {} // 2 33 | get("/blah/foo") {} // 2 34 | } 35 | 36 | private val templates = Templates.create( 37 | api, 38 | config, 39 | setOf("UserParam1", "UserParam2"), 40 | "com.example.GeneratedLambda", 41 | "testHash", 42 | "staticHash", 43 | "testApi.code", 44 | "testApi.jar", 45 | "dev", 46 | "12345678" 47 | ) 48 | 49 | fun resourceCount() { 50 | val resourceTemplate = templates.apiTemplate.rootResource 51 | assertEquals(17, resourceTemplate.resourceCount) 52 | assertEquals(2, resourceTemplate.ownResourceCount) 53 | assertEquals(4, resourceTemplate.children.size) 54 | assertEquals(4, resourceTemplate.children[0].resourceCount) // /foo 55 | assertEquals(2, resourceTemplate.children[0].ownResourceCount) 56 | assertEquals(1, resourceTemplate.children[0].children.size) 57 | assertEquals(2, resourceTemplate.children[0].children[0].resourceCount) // /foo/bar 58 | assertEquals(2, resourceTemplate.children[0].children[0].ownResourceCount) 59 | assertEquals(0, resourceTemplate.children[0].children[0].children.size) 60 | assertEquals(5, resourceTemplate.children[1].resourceCount) // /baz 61 | assertEquals(2, resourceTemplate.children[1].ownResourceCount) 62 | assertEquals(1, resourceTemplate.children[1].children.size) 63 | assertEquals(3, resourceTemplate.children[1].children[0].resourceCount) // /baz/bar 64 | assertEquals(3, resourceTemplate.children[1].children[0].ownResourceCount) 65 | assertEquals(0, resourceTemplate.children[1].children[0].children.size) 66 | } 67 | 68 | fun partitionChildren() { 69 | val resourceTemplate = templates.apiTemplate.rootResource 70 | val partitions = resourceTemplate.partitionChildren(8, 6) 71 | assertEquals(3, partitions.size) 72 | assertEquals(1, partitions[0].size) 73 | assertEquals("foo", partitions[0][0].pathPart) 74 | assertEquals(4, partitions[0][0].resourceCount) 75 | assertEquals(1, partitions[1].size) 76 | assertEquals("baz", partitions[1][0].pathPart) 77 | assertEquals(5, partitions[1][0].resourceCount) 78 | assertEquals(2, partitions[2].size) 79 | assertEquals("qux", partitions[2][0].pathPart) 80 | assertEquals(2, partitions[2][0].resourceCount) 81 | assertEquals("blah", partitions[2][1].pathPart) 82 | assertEquals(4, partitions[2][1].resourceCount) 83 | } 84 | 85 | fun partitionChildrenLarge() { 86 | // This is an anonymised version of a real API 87 | val api = api { 88 | path("/a") { 89 | post("/a/{a}") {} 90 | post("/b/{a}") {} 91 | } 92 | get("/b/{a}") {} 93 | path("/c/a") {} 94 | path("/d/a") { 95 | post("/b") {} 96 | } 97 | post("/e") {} 98 | path("/f") { 99 | post("/a") {} 100 | } 101 | path("/g") { 102 | post("/a") {} 103 | post("/b/{a}") {} 104 | } 105 | path("/h") { 106 | get("/{a}/{b}") {} 107 | } 108 | path("/i") { 109 | get("/{a}/{b}") {} 110 | post("/{a}") {} 111 | } 112 | get("/j/{a}") {} 113 | path("/k") { 114 | post("/a") {} 115 | post("/b/{a}") {} 116 | post("/c/{a}") {} 117 | get("/d/{a}") {} 118 | post("/e/{a}") {} 119 | post("/f/{a}/{b}") {} 120 | } 121 | path("/l") { 122 | post("/a") {} 123 | post("/b/{a}") {} 124 | path("/c") { 125 | get("/a/{a}/{b}") {} 126 | post("/b/{a}/{b}") {} 127 | } 128 | get("/d/{a}") {} 129 | path("/e") { 130 | post("/a") {} 131 | post("/b/{a}") {} 132 | get("/c/{a}") {} 133 | } 134 | } 135 | path("/m") { 136 | post("/a") {} 137 | post("/b/{a}") {} 138 | get("/c/{a}/a") {} 139 | post("/d/a/{a}") {} 140 | post("/d/b/{a}") {} 141 | get("/e/{a}/{b}") {} 142 | } 143 | path("/n") { 144 | path("/a") { 145 | get("/a") {} 146 | } 147 | path("/b") { 148 | get("/a/{a}") {} 149 | get("/b/{a}") {} 150 | } 151 | } 152 | path("/o") { 153 | get("/a/{a}") {} 154 | } 155 | path("/p") { 156 | post("/a") {} 157 | post("/b/{a}") {} 158 | get("/c/a/a") {} 159 | get("/c/b/a") {} 160 | get("/c/c/a") {} 161 | } 162 | path("/q") { 163 | get("/a/a") {} 164 | } 165 | path("/r") { 166 | get("/a/a") {} 167 | post("/b/{a}") {} 168 | } 169 | path("/s") { 170 | post("/a/a/{a}") {} 171 | get("/b/{a}") {} 172 | } 173 | get("/t/{a}/a") {} 174 | get("/u/{a}/{b}/{c}") {} 175 | path("/v") { 176 | post("/{a}") {} 177 | } 178 | path("/w") { 179 | post("/a") {} 180 | post("/b/{a}/{b}") {} 181 | get("/c/{a}/{b}") {} 182 | } 183 | post("/x") {} 184 | path("/y") { 185 | post("/a") {} 186 | post("/b/{a}") {} 187 | post("/c/{a}") {} 188 | get("/d/{a}") {} 189 | post("/e/{a}") {} 190 | post("/f/{a}/{b}") {} 191 | } 192 | } 193 | val templates = Templates.create( 194 | api, 195 | config, 196 | setOf("UserParam1", "UserParam2"), 197 | "com.example.GeneratedLambda", 198 | "testHash", 199 | "staticHash", 200 | "testApi.code", 201 | "testApi.jar", 202 | "dev", 203 | "12345678" 204 | ) 205 | val rootResourceTemplate = templates.apiTemplate.rootResource 206 | val partitions = rootResourceTemplate.partitionChildren(150, 200) 207 | assertEquals(2, partitions.size) 208 | assertEquals(146, partitions[0].map { it.resourceCount }.sum()) 209 | assertEquals(52, partitions[1].map { it.resourceCount }.sum()) 210 | } 211 | 212 | } 213 | -------------------------------------------------------------------------------- /aws-deploy/src/main/kotlin/ws/osiris/awsdeploy/Deploy.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.awsdeploy 2 | 3 | import com.amazonaws.services.apigateway.model.CreateDeploymentRequest 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.dataformat.yaml.YAMLFactory 6 | import org.slf4j.LoggerFactory 7 | import ws.osiris.aws.Stage 8 | import ws.osiris.aws.validateBucketName 9 | import java.io.File 10 | import java.nio.file.Path 11 | import java.util.Locale 12 | 13 | private val log = LoggerFactory.getLogger("ws.osiris.awsdeploy") 14 | 15 | /** 16 | * Deploys the API to the stages and returns the names of the stages that were updated. 17 | * 18 | * If the API is being deployed for the first time then all stages are deployed. If the API 19 | * was updated then only stages where `deployOnUpdate` is true are deployed. 20 | */ 21 | fun deployStages( 22 | profile: AwsProfile, 23 | apiId: String, 24 | apiName: String, 25 | stages: List, 26 | stackCreated: Boolean 27 | ): List { 28 | // no need to deploy stages if the stack has just been created 29 | return if (stackCreated) { 30 | stages.map { it.name } 31 | } else { 32 | val stagesToDeploy = stages.filter { it.deployOnUpdate } 33 | for (stage in stagesToDeploy) { 34 | log.debug("Updating REST API '$apiName' in stage '${stage.name}'") 35 | profile.apiGatewayClient.createDeployment(CreateDeploymentRequest().apply { 36 | restApiId = apiId 37 | stageName = stage.name 38 | variables = stage.variables 39 | description = stage.description 40 | }) 41 | } 42 | stagesToDeploy.map { it.name } 43 | } 44 | } 45 | 46 | /** 47 | * Creates an S3 bucket to hold static files. 48 | * 49 | * The bucket name is `${API name}.static-files`, converted to lower case. 50 | * 51 | * If the bucket already exists the function does nothing. 52 | */ 53 | 54 | /** 55 | * Creates an S3 bucket. 56 | * 57 | * If the bucket already exists the function does nothing. 58 | */ 59 | fun createBucket(profile: AwsProfile, bucketName: String): String { 60 | validateBucketName(bucketName) 61 | if (!profile.s3Client.doesBucketExistV2(bucketName)) { 62 | profile.s3Client.createBucket(bucketName) 63 | log.info("Created S3 bucket '$bucketName'") 64 | } else { 65 | log.info("Using existing S3 bucket '$bucketName'") 66 | } 67 | return bucketName 68 | } 69 | 70 | /** 71 | * Uploads a file to an S3 bucket and returns the URL of the file in S3. 72 | */ 73 | fun uploadFile(profile: AwsProfile, file: Path, bucketName: String, key: String? = null): String = 74 | uploadFile(profile, file, bucketName, file.parent, key) 75 | 76 | /** 77 | * Uploads a file to an S3 bucket and returns the URL of the file in S3. 78 | * 79 | * The file should be under `baseDir` on the filesystem. The S3 key for the file will be the relative path 80 | * from the base directory to the file. 81 | * 82 | * For example, if `baseDir` is `/foo/bar` and the file is `/foo/bar/baz/qux.txt` then the file will be 83 | * uploaded to S3 with the key `baz/qux.txt 84 | * 85 | * The key can be specified by the caller in which case it is used instead of automatically generating 86 | * a key. 87 | */ 88 | fun uploadFile( 89 | profile: AwsProfile, 90 | file: Path, 91 | bucketName: String, 92 | baseDir: Path, 93 | key: String? = null, 94 | bucketDir: String? = null 95 | ): String { 96 | val uploadKey = key ?: baseDir.relativize(file).toString().replace(File.separatorChar, '/') 97 | val dirPart = bucketDir?.let { "$bucketDir/" } ?: "" 98 | val fullKey = "$dirPart$uploadKey" 99 | profile.s3Client.putObject(bucketName, fullKey, file.toFile()) 100 | val url = "https://$bucketName.s3.${profile.region}.amazonaws.com/$fullKey" 101 | log.debug("Uploaded file {} to S3 bucket {}, URL {}", file, bucketName, url) 102 | return url 103 | } 104 | 105 | /** 106 | * Returns the name of a bucket for the environment and API with the specified suffix. 107 | * 108 | * The bucket name is `${appName}-${envName}-${suffix}-${accountId}`, converted to lower case. 109 | * 110 | * [accountId] is used to ensure bucket names don't clash, given that there is a single global 111 | * namespace for S3 buckets. If a clash does occur it's very hard to diagnose as there's no easy 112 | * way to find out which account owns a bucket. 113 | * 114 | * If the [envName] is `null` then the corresponding dashes aren't included. 115 | * 116 | * If the resulting bucket name is invalid an [IllegalArgumentException] is thrown. 117 | */ 118 | fun bucketName(appName: String, envName: String?, suffix: String, accountId: String): String { 119 | val bucketName = listOfNotNull(appName, envName, suffix, accountId) 120 | .filter { it.isNotBlank() } 121 | .joinToString("-") 122 | .lowercase() 123 | return validateBucketName(bucketName) 124 | } 125 | 126 | /** 127 | * Returns the default name of the S3 bucket from which code is deployed. 128 | * 129 | * The bucket name is `${appName}-${envName}-code-${accountId}`, converted to lower case. 130 | * 131 | * [accountId] is used to ensure bucket names don't clash, given that there is a single global 132 | * namespace for S3 buckets. If a clash does occur it's very hard to diagnose as there's no easy 133 | * way to find out which account owns a bucket. 134 | * 135 | * If the [envName] is `null` then the corresponding dashes aren't included. 136 | * 137 | * If the resulting bucket name is invalid an [IllegalArgumentException] is thrown. 138 | */ 139 | fun codeBucketName(appName: String, envName: String?, accountId: String): String = 140 | bucketName(appName, envName, "code", accountId) 141 | 142 | /** 143 | * Returns the name of the static files bucket for the API. 144 | * 145 | * The bucket name is `${appName}-${envName}-staticfiles-${accountId}`, converted to lower case. 146 | * 147 | * [accountId] is used to ensure bucket names don't clash, given that there is a single global 148 | * namespace for S3 buckets. If a clash does occur it's very hard to diagnose as there's no easy 149 | * way to find out which account owns a bucket. 150 | * 151 | * If the [envName] is `null` then the corresponding dashes aren't included. 152 | * 153 | * If the resulting bucket name is invalid an [IllegalArgumentException] is thrown. 154 | */ 155 | fun staticFilesBucketName(appName: String, envName: String?, accountId: String): String = 156 | bucketName(appName, envName, "staticfiles", accountId) 157 | 158 | /** 159 | * Equivalent of Maven's `MojoFailureException` - indicates something has failed during the deployment. 160 | */ 161 | class DeployException(msg: String) : RuntimeException(msg) 162 | 163 | /** 164 | * Parses `root.template` and returns a set of all parameter names passed to the generated CloudFormation template. 165 | * 166 | * These are passed to the lambda as environment variables. This allows the handler code to refer to any 167 | * AWS resources defined in `root.template`. 168 | * 169 | * This allows (for example) for lambda functions to be defined in the project, created in `root.template` 170 | * and referenced in the project via environment variables. 171 | */ 172 | @Suppress("UNCHECKED_CAST") 173 | internal fun generatedTemplateParameters(templateYaml: String, apiName: String): Set { 174 | val objectMapper = ObjectMapper(YAMLFactory()) 175 | val rootTemplateMap = objectMapper.readValue(templateYaml, Map::class.java) 176 | val parameters = (rootTemplateMap["Resources"] as Map?) 177 | ?.map { it.value as Map } 178 | ?.filter { it["Type"] == "AWS::CloudFormation::Stack" } 179 | ?.map { it["Properties"] as Map } 180 | ?.filter { (it["TemplateURL"] as? String)?.endsWith("/$apiName.template") ?: false } 181 | ?.map { it["Parameters"] as Map } 182 | ?.map { it.keys } 183 | ?.singleOrNull() ?: setOf() 184 | // These parameters are used by Osiris and don't need to be passed to the user code 185 | return parameters - "LambdaRole" - "CustomAuthArn" 186 | } 187 | 188 | -------------------------------------------------------------------------------- /core/src/main/kotlin/ws/osiris/core/Filters.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.core 2 | 3 | import com.google.gson.Gson 4 | import org.slf4j.Logger 5 | import org.slf4j.LoggerFactory 6 | import java.util.regex.Pattern 7 | import kotlin.reflect.KClass 8 | 9 | private val log: Logger = LoggerFactory.getLogger("ws.osiris.core") 10 | 11 | class Filter internal constructor(prefix: String, path: String, val handler: FilterHandler) { 12 | 13 | internal constructor(path: String, handler: FilterHandler) : this("", path, handler) 14 | 15 | private val segments: List = (prefix + path).split('/').map { it.trim() }.filter { it.isNotEmpty() } 16 | 17 | init { 18 | if (prefix.isNotEmpty() && !pathPattern.matcher(prefix).matches()) { 19 | throw IllegalArgumentException("Filter prefix format is illegal: $prefix") 20 | } 21 | if (!filterPattern.matcher(path).matches()) { 22 | throw IllegalArgumentException("Filter path is illegal: $path") 23 | } 24 | } 25 | 26 | internal fun matches(request: Request): Boolean = matches(request.requestPath.segments) 27 | 28 | // this is separate from the function above for easier testing 29 | internal fun matches(requestSegments: List): Boolean { 30 | tailrec fun matches(idx: Int): Boolean { 31 | if (idx == segments.size) return false 32 | val filterSegment = segments[idx] 33 | // If the filter paths ends /* then it matches everything 34 | if (idx == segments.size - 1 && filterSegment.isWildcard) return true 35 | if (idx == requestSegments.size) return false 36 | val requestSegment = requestSegments[idx] 37 | if (filterSegment != requestSegment && !filterSegment.isWildcard) return false 38 | if (idx == segments.size - 1 && idx == requestSegments.size - 1) return true 39 | return matches(idx + 1) 40 | } 41 | return matches(0) 42 | } 43 | 44 | private val String.isWildcard: Boolean get() = this == "*" || (this.startsWith('{') && this.endsWith('}')) 45 | 46 | companion object { 47 | /** Pattern matching the path passed in when creating a filter; allows wildcards but no part variables. */ 48 | internal val filterPattern = Pattern.compile("(?:(?:/[a-zA-Z0-9_\\-~.()]+)|(?:/\\*))+") 49 | } 50 | } 51 | 52 | /** 53 | * Creates a filter that is applied to all endpoints. 54 | * 55 | * If a filter only applies to a subset of endpoints it should be defined as part of the API. 56 | */ 57 | fun defineFilter(handler: FilterHandler): Filter = Filter("/*", handler) 58 | 59 | /** 60 | * Filter that sets the default content type of the response. 61 | * 62 | * This is done by changing the `defaultResponseHeaders` of the request. This is propagated to 63 | * the response headers via [Request.responseBuilder] function. 64 | */ 65 | fun defaultContentTypeFilter(contentType: ContentType): Filter { 66 | return defineFilter { req, handler -> 67 | val defaultHeaders = req.defaultResponseHeaders + (HttpHeaders.CONTENT_TYPE to contentType.header) 68 | val updatedReq = req.copy(defaultResponseHeaders = defaultHeaders) 69 | handler(this, updatedReq) 70 | } 71 | } 72 | 73 | /** 74 | * Filter that serialises the response body to JSON so it can be written to the response. 75 | * 76 | * This filter is only applied if the content type is `application/json`. For all other 77 | * content types the response is returned unchanged. 78 | * 79 | * Handling of body types: 80 | * * null - no body 81 | * * string - assumed to be JSON, used as-is 82 | * * object - converted to a JSON string using Jackson 83 | */ 84 | fun jsonSerialisingFilter(): Filter { 85 | val gson = Gson() 86 | return defineFilter { req, handler -> 87 | val response = handler(this, req) 88 | val contentTypeHeader = response.headers[HttpHeaders.CONTENT_TYPE] 89 | val contentType = if (contentTypeHeader == null || contentTypeHeader == JSON_CONTENT_TYPE.header) { 90 | JSON_CONTENT_TYPE 91 | } else { 92 | ContentType.parse(contentTypeHeader) 93 | } 94 | if (contentType.mimeType == MimeTypes.APPLICATION_JSON && response.body != null && response.body !is String) { 95 | response.copy(body = gson.toJson(response.body)) 96 | } else { 97 | response 98 | } 99 | } 100 | } 101 | 102 | /** 103 | * The information describing an error. 104 | * 105 | * This is returned by a exception handler when an exception occurs and is used to build a response. 106 | */ 107 | data class ErrorInfo(val status: Int, val message: String?) 108 | 109 | /** 110 | * Receives notification when an exception occurs and returns an object containing the HTTP status 111 | * and message used to build the response. 112 | */ 113 | class ExceptionHandler( 114 | private val exceptionType: KClass, 115 | private val handlerFn: (T) -> ErrorInfo 116 | ) { 117 | 118 | @Suppress("UNCHECKED_CAST") 119 | fun handle(exception: Exception): ErrorInfo? = when { 120 | exceptionType.java.isInstance(exception) -> handlerFn(exception as T) 121 | else -> null 122 | } 123 | } 124 | 125 | /** 126 | * Returns a filter that catches any exceptions thrown by the handler and builds a response containing the error 127 | * status and message. 128 | * 129 | * The exception is passed to each of the handlers in turn until one of them handles it. If none of them 130 | * handles it a 500 (server error) is returned with a generic message ("Server Error"). 131 | */ 132 | fun exceptionMappingFilter(exceptionHandlers: List>): Filter { 133 | // The info used when no handler is registered for the exception type 134 | return defineFilter { req, handler -> 135 | try { 136 | handler(this, req) 137 | } catch(e: Exception) { 138 | val info = exceptionHandlers.asSequence() 139 | .map { it.handle(e) } 140 | .filterNotNull() 141 | .firstOrNull() 142 | 143 | if (info != null) { 144 | Response.error(info.status, info.message) 145 | } else { 146 | log.error("Server Error", e) 147 | Response.error(500, "Server Error") 148 | } 149 | 150 | } 151 | } 152 | } 153 | 154 | /** 155 | * Creates an exception handler that returns an [ErrorInfo] for exceptions of a specific type. 156 | * 157 | * The info is used to build the response. 158 | * 159 | * The handler will handle any exceptions that are a subtype of the exception type. 160 | */ 161 | inline fun exceptionHandler(noinline handlerFn: (T) -> ErrorInfo): ExceptionHandler = 162 | ExceptionHandler(T::class, handlerFn) 163 | 164 | /** 165 | * Returns a filter that catches any exceptions thrown by the handler and builds a response containing the error 166 | * status and message. 167 | * 168 | * The filter catches all subclasses of the listed exceptions. Any other exceptions will result in a status 169 | * of 500 (server error) with a generic error message. 170 | * 171 | * This catches 172 | * * [HttpException], returns the status and message from the exception 173 | * * [IllegalArgumentException], returns a status of 400 (bad request) and the exception message 174 | */ 175 | fun defaultExceptionMappingFilter(): Filter { 176 | val handlers = listOf( 177 | exceptionHandler { ErrorInfo(it.httpStatus, it.message) }, 178 | exceptionHandler { ErrorInfo(400, it.message) }) 179 | return exceptionMappingFilter(handlers) 180 | } 181 | 182 | 183 | /** 184 | * The standard set of filters applied to every endpoint in an API by default. 185 | * 186 | * If the filters need to be customised a list of filters should be provided to the [api] function. 187 | */ 188 | object StandardFilters { 189 | fun create(): List> { 190 | return listOf( 191 | defaultExceptionMappingFilter(), 192 | defaultContentTypeFilter(JSON_CONTENT_TYPE), 193 | jsonSerialisingFilter()) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /aws/src/main/kotlin/ws/osiris/aws/Lambda.kt: -------------------------------------------------------------------------------- 1 | package ws.osiris.aws 2 | 3 | import com.amazonaws.services.lambda.runtime.Context 4 | import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent 5 | import org.slf4j.LoggerFactory 6 | import ws.osiris.core.Api 7 | import ws.osiris.core.Auth 8 | import ws.osiris.core.ComponentsProvider 9 | import ws.osiris.core.DataNotFoundException 10 | import ws.osiris.core.HttpMethod 11 | import ws.osiris.core.LambdaRoute 12 | import ws.osiris.core.Params 13 | import ws.osiris.core.Request 14 | import ws.osiris.core.RequestHandler 15 | import java.util.Base64 16 | import java.util.UUID 17 | import com.amazonaws.services.lambda.runtime.RequestHandler as LambdaRequestHandler 18 | 19 | /** The request attribute key used for the [Context] object passed into the lambda function by AWS. */ 20 | const val LAMBDA_CONTEXT_ATTR = "ws.osiris.aws.context" 21 | 22 | /** The request attribute key used for the event object passed into the lambda function by AWS; it is a [Map] */ 23 | const val LAMBDA_EVENT_ATTR = "ws.osiris.aws.event" 24 | 25 | /** The request attribute key used for the map of stage variables. */ 26 | const val STAGE_VARS_ATTR = "ws.osiris.aws.stagevariables" 27 | 28 | /** The resource used to identify an event coming from the keep-alive lambda instead of API Gateway. */ 29 | const val KEEP_ALIVE_RESOURCE = "[keepAlive]" 30 | 31 | /** The number of milliseconds for which to sleep after receiving a keep-alive request. */ 32 | const val KEEP_ALIVE_SLEEP = "sleepTimeMs" 33 | 34 | /** Sleep for 200ms by default. */ 35 | private const val DEFAULT_KEEP_ALIVE_SLEEP = 200 36 | 37 | private val log = LoggerFactory.getLogger("ws.osiris.aws") 38 | 39 | data class ProxyResponse( 40 | val statusCode: Int = 200, 41 | val headers: Map = mapOf(), 42 | // the weird name is required so Jackson serialises it into the JSON expected by API Gateway 43 | val isIsBase64Encoded: Boolean = false, 44 | val body: String? = null 45 | ) 46 | 47 | @Suppress("UNCHECKED_CAST") 48 | internal fun buildRequest(event: APIGatewayProxyRequestEvent, context: Context): Request { 49 | val body = event.body 50 | val isBase64Encoded = event.isBase64Encoded 51 | val requestBody: Any? = if (body is String && isBase64Encoded) Base64.getDecoder().decode(body) else body 52 | @Suppress("UNCHECKED_CAST") 53 | val requestContext = event.requestContext 54 | val stageVariables = event.stageVariables ?: mapOf() 55 | val attributes = mapOf( 56 | STAGE_VARS_ATTR to stageVariables, 57 | LAMBDA_CONTEXT_ATTR to context, 58 | LAMBDA_EVENT_ATTR to event 59 | ) 60 | return Request( 61 | HttpMethod.valueOf(event.httpMethod), 62 | event.resource, 63 | Params(event.headers), 64 | Params(event.queryStringParameters), 65 | Params(event.pathParameters), 66 | Params(requestContextMap(requestContext) + identityMap(requestContext.identity)), 67 | requestBody, 68 | attributes 69 | ) 70 | } 71 | 72 | private fun requestContextMap(context: APIGatewayProxyRequestEvent.ProxyRequestContext?): Map { 73 | if (context == null) return mapOf() 74 | val contextMap = mutableMapOf() 75 | if (context.accountId != null) contextMap["accountId"] = context.accountId 76 | if (context.stage != null) contextMap["stage"] = context.stage 77 | if (context.resourceId != null) contextMap["resourceId"] = context.resourceId 78 | if (context.requestId != null) contextMap["requestId"] = context.requestId 79 | if (context.resourcePath != null) contextMap["resourcePath"] = context.resourcePath 80 | if (context.apiId != null) contextMap["apiId"] = context.apiId 81 | if (context.path != null) contextMap["path"] = context.path 82 | context.authorizer?.filterValues { it is String }?.forEach { (k, v) -> contextMap[k] = v as String } 83 | return contextMap 84 | } 85 | 86 | private fun identityMap(identity: APIGatewayProxyRequestEvent.RequestIdentity?): Map { 87 | if (identity == null) return mapOf() 88 | val map = mutableMapOf() 89 | if (identity.cognitoIdentityPoolId != null) map["cognitoIdentityPoolId"] = identity.cognitoIdentityPoolId 90 | if (identity.accountId != null) map["accountId"] = identity.accountId 91 | if (identity.cognitoIdentityId != null) map["cognitoIdentityId"] = identity.cognitoIdentityId 92 | if (identity.caller != null) map["caller"] = identity.caller 93 | if (identity.apiKey != null) map["apiKey"] = identity.apiKey 94 | if (identity.sourceIp != null) map["sourceIp"] = identity.sourceIp 95 | if (identity.cognitoAuthenticationType != null) map["cognitoAuthenticationType"] = identity.cognitoAuthenticationType 96 | if (identity.cognitoAuthenticationProvider != null) map["cognitoAuthenticationProvider"] = identity.cognitoAuthenticationProvider 97 | if (identity.userArn != null) map["userArn"] = identity.userArn 98 | if (identity.userAgent != null) map["userAgent"] = identity.userAgent 99 | if (identity.user != null) map["user"] = identity.user 100 | if (identity.accessKey != null) map["accessKey"] = identity.accessKey 101 | return map 102 | } 103 | 104 | @Suppress("unused") 105 | abstract class ProxyLambda(api: Api, private val components: T) : 106 | LambdaRequestHandler { 107 | 108 | /** The HTTP request handlers, keyed by the HTTP method and path they handle. */ 109 | private val routeMap: Map, RequestHandler> 110 | 111 | /** Unique ID of the lambda instance; this helps figure out how many function instances are live. */ 112 | private val id = UUID.randomUUID() 113 | 114 | init { 115 | log.debug("Creating ProxyLambda") 116 | // TODO this doesn't work for CORS OPTIONS methods because they're only added when building RouteNodes 117 | // which are only used in the template. the OPTIONS routes need to be added to the Api 118 | routeMap = api.routes 119 | .filterIsInstance>() 120 | .associateBy({ Pair(it.method, it.path) }, { it.handler }) 121 | log.debug("Created routes: {}", routeMap.keys) 122 | } 123 | 124 | override fun handleRequest(requestEvent: APIGatewayProxyRequestEvent, context: Context): ProxyResponse { 125 | log.debug("Function {} handling requestEvent {}", id, requestEvent) 126 | if (keepAlive(requestEvent)) return ProxyResponse() 127 | val request = buildRequest(requestEvent, context) 128 | log.debug("Request endpoint: {} {}", request.method, request.path) 129 | val handler = routeMap[Pair(request.method, request.path)] ?: throw DataNotFoundException() 130 | log.debug("Invoking handler") 131 | val response = handler.invoke(components, request) 132 | log.debug("Invoked handler") 133 | val proxyResponse = when (val body = response.body) { 134 | null -> ProxyResponse(response.status, response.headers.headerMap, false, null) 135 | is ByteArray -> ProxyResponse(response.status, response.headers.headerMap, true, encodeBinaryBody(body)) 136 | is String -> ProxyResponse(response.status, response.headers.headerMap, false, body) 137 | else -> throw IllegalStateException("Response must contain null, a string or a ByteArray") 138 | } 139 | log.debug("Returning response: {}", proxyResponse) 140 | return proxyResponse 141 | } 142 | 143 | private fun encodeBinaryBody(byteArray: ByteArray): String = 144 | String(Base64.getEncoder().encode(byteArray), Charsets.UTF_8) 145 | 146 | /** 147 | * If the request is not a keep-alive request this function immediately returns false; otherwise it sleeps 148 | * for the time specified in the message and returns true 149 | */ 150 | private fun keepAlive(requestEvent: APIGatewayProxyRequestEvent): Boolean { 151 | if (requestEvent.resource != KEEP_ALIVE_RESOURCE) return false 152 | val sleepTimeMs = requestEvent.headers[KEEP_ALIVE_SLEEP]?.toInt() ?: DEFAULT_KEEP_ALIVE_SLEEP 153 | log.debug("Keep-alive request received. Sleeping for {}ms. Function {}", sleepTimeMs, id) 154 | Thread.sleep(sleepTimeMs.toLong()) 155 | return true 156 | } 157 | } 158 | 159 | /** 160 | * Represents the AWS authorisation type "AWS_IAM"; callers must include headers with credentials for 161 | * an AWS IAM user. 162 | */ 163 | object IamAuth : Auth { 164 | override val name: String = "AWS_IAM" 165 | } 166 | 167 | /** 168 | * Represents the AWS authorisation type "COGNITO_USER_POOLS"; the user must login to a Cognito user 169 | * pool and provide the token when calling the API. 170 | */ 171 | object CognitoUserPoolsAuth : Auth { 172 | override val name: String = "COGNITO_USER_POOLS" 173 | } 174 | 175 | /** 176 | * Represents the AWS authorisation type "CUSTOM"; the authorisation is carried out by custom logic in a lambda. 177 | */ 178 | object CustomAuth : Auth { 179 | override val name: String = "CUSTOM" 180 | } 181 | --------------------------------------------------------------------------------