diff --git a/Examples/quoteapi/.gitignore b/Examples/quoteapi/.gitignore new file mode 100644 index 0000000..96a6fbc --- /dev/null +++ b/Examples/quoteapi/.gitignore @@ -0,0 +1,9 @@ +/.aws-sam +/.build +/.swiftpm +/.vscode +Package.resolved +samconfig.toml +*.d +*o +*swiftdeps \ No newline at end of file diff --git a/Examples/quoteapi/Dockerfile b/Examples/quoteapi/Dockerfile new file mode 100644 index 0000000..4f363e0 --- /dev/null +++ b/Examples/quoteapi/Dockerfile @@ -0,0 +1,3 @@ +# image used to compile your Swift code +FROM public.ecr.aws/docker/library/swift:5.9.1-amazonlinux2 +RUN yum -y install git jq tar zip openssl-devel diff --git a/Examples/quoteapi/Makefile b/Examples/quoteapi/Makefile new file mode 100644 index 0000000..1c29e7a --- /dev/null +++ b/Examples/quoteapi/Makefile @@ -0,0 +1,62 @@ +### Add functions here and link them to builder-bot format MUST BE "build-FunctionResourceName in template.yaml" + +build-QuoteService: builder-bot + +# Helper commands +build: + sam build + +deploy: + sam deploy + +logs: + sam logs --stack-name QuoteService --name QuoteService + +tail: + sam logs --stack-name QuoteService --name QuoteService --tail + +local: + LOCAL_LAMBDA_SERVER_ENABLED=true swift run QuoteService + +invoke: + curl -v -H 'Authorization: 123' https://k3lbszo7x6.execute-api.us-east-1.amazonaws.com/stocks/AAPL + +###################### No Change required below this line ########################## + +builder-bot: + $(eval $@PRODUCT = $(subst build-,,$(MAKECMDGOALS))) + $(eval $@BUILD_DIR = $(PWD)/.aws-sam/build-swift) + $(eval $@STAGE = $($@BUILD_DIR)/lambda) + $(eval $@ARTIFACTS_DIR = $(PWD)/.aws-sam/build/$($@PRODUCT)) + +## Building from swift-openapi-lambda in a local directory (not from Github) +## 1. git clone https://github.com/swift-server/swift-openapi-lambda .. +## 2. Change `Package.swift` dependency to ../swift-openapi-lambda + +## 3. add /.. to BUILD_SRC +## $(eval $@BUILD_SRC = $(PWD)/..) + $(eval $@BUILD_SRC = $(PWD)) + +## 4. add `cd quoteapi &&` to the docker BUILD_CMD +## $(eval $@BUILD_CMD = "ls && cd quoteapi && swift build --static-swift-stdlib --product $($@PRODUCT) -c release --build-path /build-target") + + $(eval $@BUILD_CMD = "swift build --static-swift-stdlib --product $($@PRODUCT) -c release --build-path /build-target") + + # build docker image to compile Swift for Linux + docker build -f Dockerfile . -t swift-builder + + # prep directories + mkdir -p $($@BUILD_DIR)/lambda $($@ARTIFACTS_DIR) + + # compile application inside Docker image using source code from local project folder + + docker run --rm -v $($@BUILD_DIR):/build-target -v $($@BUILD_SRC):/build-src -w /build-src swift-builder bash -cl $($@BUILD_CMD) + + # create lambda bootstrap file + docker run --rm -v $($@BUILD_DIR):/build-target -v `pwd`:/build-src -w /build-src swift-builder bash -cl "cd /build-target/lambda && ln -s $($@PRODUCT) /bootstrap" + + # copy binary to stage + cp $($@BUILD_DIR)/release/$($@PRODUCT) $($@STAGE)/bootstrap + + # copy app from stage to artifacts dir + cp $($@STAGE)/* $($@ARTIFACTS_DIR) diff --git a/Examples/quoteapi/Package.swift b/Examples/quoteapi/Package.swift new file mode 100644 index 0000000..da8d483 --- /dev/null +++ b/Examples/quoteapi/Package.swift @@ -0,0 +1,44 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "QuoteService", + platforms: [ + .macOS(.v13), .iOS(.v15), .tvOS(.v15), .watchOS(.v6), + ], + products: [ + .executable(name: "QuoteService", targets: ["QuoteService"]) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-openapi-generator.git", from: "1.4.0"), + .package(url: "https://github.com/apple/swift-openapi-runtime.git", from: "1.5.0"), + .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", from: "1.0.0-alpha.3"), + .package(url: "https://github.com/swift-server/swift-aws-lambda-events.git", from: "0.4.0"), + .package(url: "https://github.com/swift-server/swift-openapi-lambda.git", from: "0.2.0"), + // .package(name: "swift-openapi-lambda", path: "../swift-openapi-lambda") + ], + targets: [ + .executableTarget( + name: "QuoteService", + dependencies: [ + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), + .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events"), + .product(name: "OpenAPILambda", package: "swift-openapi-lambda"), + ], + path: "Sources", + resources: [ + .copy("openapi.yaml"), + .copy("openapi-generator-config.yaml"), + ], + plugins: [ + .plugin( + name: "OpenAPIGenerator", + package: "swift-openapi-generator" + ) + ] + ) + ] +) diff --git a/Examples/quoteapi/README.md b/Examples/quoteapi/README.md new file mode 100644 index 0000000..b68303e --- /dev/null +++ b/Examples/quoteapi/README.md @@ -0,0 +1,79 @@ +# QuoteAPI + +This application illustrates how to deploy a Server-Side Swift workload on AWS using the [AWS Serverless Application Model (SAM)](https://aws.amazon.com/serverless/sam/) toolkit. The workload is a simple REST API that returns a string from an Amazon API Gateway. Requests to the API Gateway endpoint are handled by an AWS Lambda Function written in Swift. + + +## Prerequisites + +To build this sample application, you need: + +- [AWS Account](https://console.aws.amazon.com/) +- [AWS Command Line Interface (AWS CLI)](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html) - install the CLI and [configure](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html) it with credentials to your AWS account +- [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) - a command-line tool used to create serverless workloads on AWS +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) - to compile your Swift code for Linux deployment to AWS Lambda + +## Build the application + +The **sam build** command uses Docker to compile your Swift Lambda function and package it for deployment to AWS. + +```bash +sam build +``` + +## Deploy the application + +The **sam deploy** command creates the Lambda function and API Gateway in your AWS account. + +```bash +sam deploy --guided +``` + +Accept the default response to every prompt, except the following warning: + +```bash +QuoteService may not have authorization defined, Is this okay? [y/N]: y +``` + +The project creates a publicly accessible API endpoint. This is a warning to inform you the API does not have authorization. If you are interested in adding authorization to the API, please refer to the [SAM Documentation](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-httpapi.html). + +## Use the API + +At the end of the deployment, SAM displays the endpoint of your API Gateway: + +```bash +Outputs +---------------------------------------------------------------------------------------- +Key SwiftAPIEndpoint +Description API Gateway endpoint URL for your application +Value https://[your-api-id].execute-api.[your-aws-region].amazonaws.com +---------------------------------------------------------------------------------------- +``` + +Use cURL or a tool such as [Postman](https://www.postman.com/) to interact with your API. Replace **[your-api-endpoint]** with the SwiftAPIEndpoint value from the deployment output. + +**Invoke the API Endpoint** + +```bash +curl https://[your-api-endpoint]/stocks/AMZN +``` + +## Test the API Locally +SAM also allows you to execute your Lambda functions locally on your development computer. Follow these instructions to execute the Lambda function locally. Further capabilities can be explored in the [SAM Documentation](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-using-invoke.html). + +**Event Files** + +When a Lambda function is invoked, API Gateway sends an event to the function with all the data packaged with the API call. When running the functions locally, you pass in a json file to the function that simulates the event data. The **events** folder contains a json file for the function. + +**Invoke the Lambda Function Locally** + +```bash +sam local invoke QuoteService --event events/GetQuote.json +``` + +## Cleanup + +When finished with your application, use SAM to delete it from your AWS account. Answer **Yes (y)** to all prompts. This will delete all of the application resources created in your AWS account. + +```bash +sam delete +``` diff --git a/Examples/quoteapi/Sources/QuoteService.swift b/Examples/quoteapi/Sources/QuoteService.swift new file mode 100644 index 0000000..d233596 --- /dev/null +++ b/Examples/quoteapi/Sources/QuoteService.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenAPI Lambda open source project +// +// Copyright (c) 2023 Amazon.com, Inc. or its affiliates +// and the Swift OpenAPI Lambda project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift OpenAPI Lambda project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import OpenAPIRuntime +import OpenAPILambda + +@main +struct QuoteServiceImpl: APIProtocol, OpenAPILambdaHttpApi { + + init(transport: OpenAPILambdaTransport) throws { + try self.registerHandlers(on: transport) + } + + func getQuote(_ input: Operations.getQuote.Input) async throws -> Operations.getQuote.Output { + + let symbol = input.path.symbol + + var date: Date = Date() + if let dateString = input.query.date { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyyMMdd" + date = dateFormatter.date(from: dateString) ?? Date() + } + + let price = Components.Schemas.quote( + symbol: symbol, + price: Double.random(in: 100..<150).rounded(), + change: Double.random(in: -5..<5).rounded(), + changePercent: Double.random(in: -0.05..<0.05), + volume: Double.random(in: 10000..<100000).rounded(), + timestamp: date + ) + + return .ok(.init(body: .json(price))) + } +} diff --git a/Examples/quoteapi/Sources/openapi-generator-config.yaml b/Examples/quoteapi/Sources/openapi-generator-config.yaml new file mode 100644 index 0000000..a7c6c0f --- /dev/null +++ b/Examples/quoteapi/Sources/openapi-generator-config.yaml @@ -0,0 +1,4 @@ +generate: + - types + - server + \ No newline at end of file diff --git a/Examples/quoteapi/Sources/openapi.yaml b/Examples/quoteapi/Sources/openapi.yaml new file mode 100644 index 0000000..726129f --- /dev/null +++ b/Examples/quoteapi/Sources/openapi.yaml @@ -0,0 +1,54 @@ +openapi: 3.1.0 +info: + title: StockQuoteService + version: 1.0.0 + +components: + schemas: + quote: + type: object + properties: + symbol: + type: string + price: + type: number + change: + type: number + changePercent: + type: number + volume: + type: number + timestamp: + type: string + format: date-time + +paths: + /stocks/{symbol}: + get: + summary: Get the latest quote for a stock + operationId: getQuote + parameters: + - name: symbol + in: path + required: true + schema: + type: string + - name: date + in: query + required: false + schema: + type: string + format: date + tags: + - stocks + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/quote' + 400: + description: Bad Request + 404: + description: Not Found diff --git a/Examples/quoteapi/events/GetQuote.json b/Examples/quoteapi/events/GetQuote.json new file mode 100644 index 0000000..40e0e20 --- /dev/null +++ b/Examples/quoteapi/events/GetQuote.json @@ -0,0 +1,34 @@ +{ + "rawQueryString": "", + "headers": { + "host": "b2k1t8fon7.execute-api.us-east-1.amazonaws.com", + "x-forwarded-port": "443", + "content-length": "0", + "x-amzn-trace-id": "Root=1-6571d134-63dbe8ee21efa87555d59265", + "x-forwarded-for": "191.95.148.219", + "x-forwarded-proto": "https", + "accept": "*/*", + "user-agent": "curl/8.1.2" + }, + "requestContext": { + "apiId": "b2k1t8fon7", + "http": { + "sourceIp": "191.95.148.219", + "userAgent": "curl/8.1.2", + "method": "GET", + "path": "/stocks/AAPL", + "protocol": "HTTP/1.1" + }, + "timeEpoch": 1701957940365, + "domainPrefix": "b2k1t8fon7", + "accountId": "486652066693", + "time": "07/Dec/2023:14:05:40 +0000", + "stage": "$default", + "domainName": "b2k1t8fon7.execute-api.us-east-1.amazonaws.com", + "requestId": "Pk2gOia2IAMEPOw=" + }, + "isBase64Encoded": false, + "version": "2.0", + "routeKey": "$default", + "rawPath": "/stocks/AAPL" +} \ No newline at end of file diff --git a/Examples/quoteapi/template.yml b/Examples/quoteapi/template.yml new file mode 100644 index 0000000..3e487da --- /dev/null +++ b/Examples/quoteapi/template.yml @@ -0,0 +1,61 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: SAM Template for QuoteService + +Globals: + Function: + Timeout: 60 + CodeUri: . + Handler: swift.bootstrap + Runtime: provided.al2 + MemorySize: 512 + Architectures: + - arm64 + +Resources: + # Lambda function + QuoteService: + Type: AWS::Serverless::Function + Properties: + Environment: + Variables: + # by default, AWS Lambda runtime produces no log + # use `LOG_LEVEL: debug` for for lifecycle and event handling information + # use `LOG_LEVEL: trace` for detailed input event information + LOG_LEVEL: trace + + Events: + # pass through all HTTP verbs and paths + Api: + Type: HttpApi + Properties: + ApiId: !Ref MyProtectedApi + Path: /{proxy+} + Method: ANY + Auth: + Authorizer: MyLambdaAuthorizer + + Metadata: + BuildMethod: makefile + + MyProtectedApi: + Type: AWS::Serverless::HttpApi + Properties: + Auth: + DefaultAuthorizer: MyLambdaAuthorizer + Authorizers: + MyLambdaAuthorizer: + AuthorizerPayloadFormatVersion: 2.0 + EnableFunctionDefaultPermissions: true + EnableSimpleResponses: true + FunctionArn: arn:aws:lambda:us-east-1:486652066693:function:LambdaAuthorizer-LambdaAuthorizer-TSH4AsHiqICi + Identity: + Headers: + - Authorization + +# print API endpoint +Outputs: + SwiftAPIEndpoint: + Description: "API Gateway endpoint URL for your application" + Value: !Sub "https://${MyProtectedApi}.execute-api.${AWS::Region}.amazonaws.com" + # Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com" diff --git a/scripts/check_format.sh b/scripts/check_format.sh new file mode 100755 index 0000000..3ae555d --- /dev/null +++ b/scripts/check_format.sh @@ -0,0 +1,60 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift OpenAPI Lambda open source project +## +## Copyright (c) 2023 Amazon.com, Inc. or its affiliates +## and the Swift OpenAPI Lambda project authors +## Licensed under Apache License v2.0 +## +## See LICENSE.txt for license information +## See CONTRIBUTORS.txt for the list of Swift OpenAPI Lambda project authors +## +## SPDX-License-Identifier: Apache-2.0 +## +##===----------------------------------------------------------------------===## + +# ===----------------------------------------------------------------------===// +# +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2024 Apple Inc. and the Swift project authors +# Licensed under Apache License v2.0 with Runtime Library Exception +# +# See https://swift.org/LICENSE.txt for license information +# See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +# +# ===----------------------------------------------------------------------===// + +set -euo pipefail + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + + +if [[ -f .swiftformatignore ]]; then + log "Found swiftformatignore file..." + + log "Running swift format format..." + tr '\n' '\0' < .swiftformatignore| xargs -0 -I% printf '":(exclude)%" '| xargs git ls-files -z '*.swift' | xargs -0 swift format format --parallel --in-place + + log "Running swift format lint..." + + tr '\n' '\0' < .swiftformatignore | xargs -0 -I% printf '":(exclude)%" '| xargs git ls-files -z '*.swift' | xargs -0 swift format lint --strict --parallel +else + log "Running swift format format..." + git ls-files -z '*.swift' | xargs -0 swift format format --parallel --in-place + + log "Running swift format lint..." + + git ls-files -z '*.swift' | xargs -0 swift format lint --strict --parallel +fi + + + +log "Checking for modified files..." + +GIT_PAGER='' git diff --exit-code '*.swift' + +log "✅ Found no formatting issues." diff --git a/scripts/check_license.sh b/scripts/check_license.sh new file mode 100755 index 0000000..240c7d5 --- /dev/null +++ b/scripts/check_license.sh @@ -0,0 +1,112 @@ +#!/bin/bash +##===----------------------------------------------------------------------===## +## +## This source file is part of the Swift OpenAPI Lambda open source project +## +## Copyright (c) 2023 Amazon.com, Inc. or its affiliates +## and the Swift OpenAPI Lambda project authors +## Licensed under Apache License v2.0 +## +## See LICENSE.txt for license information +## See CONTRIBUTORS.txt for the list of Swift OpenAPI Lambda project authors +## +## SPDX-License-Identifier: Apache-2.0 +## +##===----------------------------------------------------------------------===## + +# ===----------------------------------------------------------------------===// +# +# This source file is part of the Swift.org open source project +# +# Copyright (c) 2024 Apple Inc. and the Swift project authors +# Licensed under Apache License v2.0 with Runtime Library Exception +# +# See https://swift.org/LICENSE.txt for license information +# See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +# +# ===----------------------------------------------------------------------===// + +set -euo pipefail + +set +x + +log() { printf -- "** %s\n" "$*" >&2; } +error() { printf -- "** ERROR: %s\n" "$*" >&2; } +fatal() { error "$@"; exit 1; } + +test -n "${PROJECT_NAME:-}" || fatal "PROJECT_NAME unset" + +if [ -f .license_header_template ]; then + # allow projects to override the license header template + expected_file_header_template=$(cat .license_header_template) +else + expected_file_header_template="@@===----------------------------------------------------------------------===@@ +@@ +@@ This source file is part of the ${PROJECT_NAME} open source project +@@ +@@ Copyright (c) YEARS Apple Inc. and the ${PROJECT_NAME} project authors +@@ Licensed under Apache License v2.0 +@@ +@@ See LICENSE.txt for license information +@@ See CONTRIBUTORS.txt for the list of ${PROJECT_NAME} project authors +@@ +@@ SPDX-License-Identifier: Apache-2.0 +@@ +@@===----------------------------------------------------------------------===@@" +fi + +paths_with_missing_license=( ) + +# file_excludes=".license_header_template +# .licenseignore" +# if [ -f .licenseignore ]; then +# file_excludes=$(printf '%s\n%s' "$file_excludes" "$(cat .licenseignore)") +# fi +# file_paths=$(echo "$file_excludes" | tr '\n' '\0' | xargs -0 -I% printf '":(exclude)%" '| xargs git ls-files) +file_paths=$(tr '\n' '\0' < .licenseignore | xargs -0 -I% printf '":(exclude)%" '| xargs git ls-files ":(exclude).licenseignore" ":(exclude).license_header_template" ) +echo $file_paths + +while IFS= read -r file_path; do + file_basename=$(basename -- "${file_path}") + file_extension="${file_basename##*.}" + + # shellcheck disable=SC2001 # We prefer to use sed here instead of bash search/replace + case "${file_extension}" in + swift) expected_file_header=$(sed -e 's|@@|//|g' <<<"${expected_file_header_template}") ;; + h) expected_file_header=$(sed -e 's|@@|//|g' <<<"${expected_file_header_template}") ;; + c) expected_file_header=$(sed -e 's|@@|//|g' <<<"${expected_file_header_template}") ;; + sh) expected_file_header=$(cat <(echo '#!/bin/bash') <(sed -e 's|@@|##|g' <<<"${expected_file_header_template}")) ;; + kts) expected_file_header=$(sed -e 's|@@|//|g' <<<"${expected_file_header_template}") ;; + gradle) expected_file_header=$(sed -e 's|@@|//|g' <<<"${expected_file_header_template}") ;; + groovy) expected_file_header=$(sed -e 's|@@|//|g' <<<"${expected_file_header_template}") ;; + java) expected_file_header=$(sed -e 's|@@|//|g' <<<"${expected_file_header_template}") ;; + py) expected_file_header=$(cat <(echo '#!/usr/bin/env python3') <(sed -e 's|@@|##|g' <<<"${expected_file_header_template}")) ;; + rb) expected_file_header=$(cat <(echo '#!/usr/bin/env ruby') <(sed -e 's|@@|##|g' <<<"${expected_file_header_template}")) ;; + in) expected_file_header=$(sed -e 's|@@|##|g' <<<"${expected_file_header_template}") ;; + cmake) expected_file_header=$(sed -e 's|@@|##|g' <<<"${expected_file_header_template}") ;; + *) + error "Unsupported file extension ${file_extension} for file (exclude or update this script): ${file_path}" + paths_with_missing_license+=("${file_path} ") + ;; + esac + expected_file_header_linecount=$(wc -l <<<"${expected_file_header}") + + file_header=$(head -n "${expected_file_header_linecount}" "${file_path}") + normalized_file_header=$( + echo "${file_header}" \ + | sed -e 's/20[12][0123456789]-20[12][0123456789]/YEARS/' -e 's/20[12][0123456789]/YEARS/' \ + ) + + if ! diff -u \ + --label "Expected header" <(echo "${expected_file_header}") \ + --label "${file_path}" <(echo "${normalized_file_header}") + then + paths_with_missing_license+=("${file_path} ") + fi +done <<< "$file_paths" + +if [ "${#paths_with_missing_license[@]}" -gt 0 ]; then + fatal "❌ Found missing license header in files: ${paths_with_missing_license[*]}." +fi + +log "✅ Found no files with missing license header." diff --git a/yamllint.yml b/yamllint.yml new file mode 100644 index 0000000..52a1770 --- /dev/null +++ b/yamllint.yml @@ -0,0 +1,7 @@ +extends: default + +rules: + line-length: false + document-start: false + truthy: + check-keys: false # Otherwise we get a false positive on GitHub action's `on` key