diff --git a/.assay-it.json b/.assay-it.json new file mode 100644 index 0000000..a76c5f9 --- /dev/null +++ b/.assay-it.json @@ -0,0 +1,5 @@ +{ + "suites": [ + "http/suites/petshop.go" + ] +} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 881e6e2..2305610 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,12 +18,18 @@ jobs: - uses: actions/checkout@v2 - - uses: fogfish/deploy-cdk-go@latest + - id: deploy + uses: fogfish/deploy-cdk-go@latest with: - go-version: 1.18 + go-version: "1.20" stack: ${{ matrix.stack }} version: latest issue-to-create: ./.github/issue-spawn-latest.md aws-access-key: ${{ secrets.AWS_ACCESS_KEY }} aws-secret-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: eu-west-1 + + - uses: assay-it/github-actions-quality-check@latest + with: + install-go: false + system-under-test: ${{ steps.deploy.outputs.deployed-api }} diff --git a/.github/workflows/carry.yml b/.github/workflows/carry.yml index 9f2ba9f..cf056b5 100644 --- a/.github/workflows/carry.yml +++ b/.github/workflows/carry.yml @@ -15,12 +15,18 @@ jobs: - uses: actions/checkout@v2 - - uses: fogfish/deploy-cdk-go@latest + - id: deploy + uses: fogfish/deploy-cdk-go@latest with: - go-version: 1.18 + go-version: "1.20" stack: ${{ matrix.stack }} version: ${{ github.event.release.name }} issue-to-create: ./.github/issue-spawn-release.md aws-access-key: ${{ secrets.AWS_ACCESS_KEY }} aws-secret-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: eu-west-1 + + - uses: assay-it/github-actions-quality-check@latest + with: + install-go: false + system-under-test: ${{ steps.deploy.outputs.deployed-api }} diff --git a/.github/workflows/check-clean.yml b/.github/workflows/check-clean.yml index 1ca03f5..0fb1846 100644 --- a/.github/workflows/check-clean.yml +++ b/.github/workflows/check-clean.yml @@ -21,7 +21,7 @@ jobs: - uses: fogfish/deploy-cdk-go@latest with: - go-version: 1.18 + go-version: "1.20" command: destroy stack: ${{ matrix.stack }} version: pr${{ github.event.number }} diff --git a/.github/workflows/check-code.yml b/.github/workflows/check-code.yml index 5359b25..0d1b2f0 100644 --- a/.github/workflows/check-code.yml +++ b/.github/workflows/check-code.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-go@v3 with: - go-version: 1.18 + go-version: "1.20" cache: true - uses: dominikh/staticcheck-action@v1.2.0 diff --git a/.github/workflows/check-spawn.yml b/.github/workflows/check-spawn.yml index e6609dd..2b775f6 100644 --- a/.github/workflows/check-spawn.yml +++ b/.github/workflows/check-spawn.yml @@ -20,9 +20,10 @@ jobs: steps: - uses: actions/checkout@v2 - - uses: fogfish/deploy-cdk-go@latest + - id: deploy + uses: fogfish/deploy-cdk-go@latest with: - go-version: 1.18 + go-version: "1.20" stack: ${{ matrix.stack }} version: pr${{ github.event.number }} issue-to-comment: ${{ github.event.number }} @@ -30,6 +31,11 @@ jobs: aws-secret-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: eu-west-1 + - uses: assay-it/github-actions-quality-check@latest + with: + install-go: false + system-under-test: ${{ steps.deploy.outputs.deployed-api }} + - uses: buildsville/add-remove-label@v1 if: always() with: diff --git a/.github/workflows/check-test.yml b/.github/workflows/check-test.yml index 442dad7..2d97fe9 100644 --- a/.github/workflows/check-test.yml +++ b/.github/workflows/check-test.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/setup-go@v3 with: - go-version: 1.18 + go-version: "1.20" cache: true - name: go build diff --git a/.gitignore b/.gitignore index d8209c0..6691965 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ node_modules/ .vscode/ +suites/assay-it/ diff --git a/README.md b/README.md index 26ec9d7..46776ba 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,12 @@ This project crafts a fully functional blueprint of Golang serverless RESTful ap [AWS CDK](https://aws.amazon.com/cdk) is amazing technology to automate the development and operation of application into one process and one codebase. -However, seeding of new repository for development of Golang serverless application requires a boilerplate code. This blueprint helps you to focus on the application development than waste a time with establish project layout, configure AWS CDK, setting up CI/CD and figuring out how to testing the application. All these issues are resolved within this blueprint. +However, seeding of new repository for development of Golang serverless application requires a boilerplate code. This blueprint helps you to focus on the application development than waste a time with establish **project layout**, **configure AWS CDK**, **setting up CI/CD** and figuring out how to **testing the application**. All these issues are resolved within this blueprint. + ## Installation -The blueprint is fully functional application that delivers a skeleton for Golang serverless development with AWS CDK. Clone the repository and follow [Getting started](#getting-started) instructions to evaluate its applicability for your purposes. It should take less than 5 minutes to build and deploy the template in AWS. +The blueprint is fully functional application (Pet Store) that delivers a skeleton for Golang serverless development with AWS CDK. Clone the repository and follow [Getting started](#getting-started) instructions to evaluate its applicability for your purposes. It should take less than 5 minutes to build and deploy this blueprint to AWS. ``` go get github.com/fogfish/blueprint-serverless-golang @@ -75,64 +76,86 @@ git merge blueprint/main --allow-unrelated-histories --squash Before Getting started, you have to ensure * [Golang](https://golang.org/dl/) development environment v1.16 or later -* [AWS CDK](https://docs.aws.amazon.com/cdk/latest/guide/work-with.html#work-with-prerequisites) -* Access to AWS Account +* [assay-it](https://assay.it) utility for testing cloud apps in production +* [AWS CDK](https://docs.aws.amazon.com/cdk/latest/guide/work-with.html#work-with-prerequisites) for deployment of serverless application using infrastructure as a code +* [GitHub](https://github.com) account for managing source code and running CI/CD pipelines as [GitHub Actions](https://docs.github.com/en/actions) +* Account on [Amazon Web Services](https://aws.amazon.com) for running the application in production ## Getting started **Let's have a look on the repository structure** -The structure resembles the standard package layout proposed in [this blog post](https://medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1): +The structure resembles the mixture of [Standard package layout](https://medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1) and [Hexagonal architecture](https://medium.com/@matiasvarela/hexagonal-architecture-in-go-cfd4e436faa3). The proposed structure is better version of Hexagonal architecture that follows Golang best practices: 1. the root package contains core types to describe domain of your application. It contains simple types that has no dependency to technology but their implements core logic and use-cases. -2. Use sub-packages to isolate dependencies to external technologies so that they act as bridge between your domain and technology adaptation. +2. Use sub-packages to isolate dependencies to external technologies so that they act as bridge between your domain and technology adaptation. 3. Main packages build lambda functions and ties everything together. ``` github.com/.../the-beautiful-app - ├─ stub.go // domain types, unit test - ├─ ... // "algebra" of your application - | - ├─ http // RESTful API and HTTP protocol - | ├─ api.go // api endpoint(s), unit tests, - | └─ ... // other endpoints - | - ├─ cmd // executables of the project - | └─ lambda // aws lambda's are main packages - | ├─ scud // each lambda stays at own executable - | | └─ main.go - | └─ ... - | - ├─ cloud // IaC, aws cdk application - | └─ ... // orchestrate ops model - | - ├─ .github // CI/CD with GitHub Actions - | └─ ... - | - └─ suite // API testing suite - ├─ api.go // (disabled in this release) - └─ ... +├─ pet.go // the root defines domain types, unit test +├─ ... // "algebra" of your application +| +├─ storage.go // defines capability requires to store core +| // objects at the external storage, hex-arch +| // use "port" concept to depict it +| +├─ internal/storage // sub-package for dependency/technology ... +| // it follows the standard package layout to +| // adapt domain/implementation/dependency. +| // in this example storage implements in-memory +| // database for all domain objects. +| +├─ internal/services // entry point to the core, implement app logic +| └─ pets // entire logic about pets domain +| ├─ fetcher.go // fetch and enrich pets objects +| └─ creator.go // create pets objects +| +├─ internal/mock // shared mock +| +├─ http // public REST API exposed by application. +| ├─ petshop.go // collection of petshop endpoints impl. by app +| | // endpoints consumer services using ports +| | +| ├─ api // public objects used by API +| | └─ pet.go +| └─ suites // testing suites for api endpoint(s) +| +├─ cmd // executables of the project +| ├─ lambda // aws lambda's are main packages +| | ├─ petshop // each lambda stays at own executable +| | | └─ main.go // single lambda pattern is not recommended +| | ... +| └─ server // run application as standalone server +| └─ main.go +| +├─ cloud // IaC, aws cdk application +| └─ ... // orchestrate ops model +| +└─ .github // CI/CD with GitHub Actions + └─ ... ``` ### Development workflows -**dependencies** +**unit testing** -The application requires 3rd party libraries for dev and opts. Fetch them with the following commands: +Test the Golang application and its cloud infrastructure ```bash -go get -d ./... +go test ./... ``` -**unit testing** +**local testing** -Test the Golang application and its cloud infrastructure +Run application locally ```bash -go test ./... +go run cmd/server/main.go +assay-it test --target http://127.1:8080 ``` **build** @@ -154,9 +177,16 @@ cdk deploy In few seconds, the application becomes available at ``` -curl https://xxxxxxxxxx.execute-api.eu-west-1.amazonaws.com/api/scud +curl https://xxxxxxxxxx.execute-api.eu-west-1.amazonaws.com/api +``` + +**test in production** + +```bash +assay-it test --target https://xxxxxxxxxx.execute-api.eu-west-1.amazonaws.com/api ``` + **destroy** Destroy the application and remove all its resource from AWS account diff --git a/cloud/blueprint.go b/cloud/blueprint.go index 9d4955b..cdd391f 100644 --- a/cloud/blueprint.go +++ b/cloud/blueprint.go @@ -11,7 +11,6 @@ import ( "github.com/fogfish/scud" ) -// func vsn(app awscdk.App) string { switch val := app.Node().TryGetContext(jsii.String("vsn")).(type) { case string: @@ -44,20 +43,19 @@ func main() { app.Synth(nil) } -// // NewBlueprint create example REST api func NewBlueprint(scope constructs.Construct) { gateway := scud.NewGateway(scope, jsii.String("Gateway"), &awsapigateway.RestApiProps{ - RestApiName: jsii.String("scud"), + RestApiName: jsii.String("petshop"), }, ) myfun := scud.NewFunctionGo(scope, jsii.String("MyFun"), &scud.FunctionGoProps{ SourceCodePackage: "github.com/fogfish/blueprint-serverless-golang", - SourceCodeLambda: "cmd/lambda/scud", + SourceCodeLambda: "cmd/lambda/petshop", }, ) - gateway.AddResource("scud", myfun) + gateway.AddResource("petshop", myfun) } diff --git a/cloud/blueprint_test.go b/cloud/blueprint_test.go index af1cad2..3236179 100644 --- a/cloud/blueprint_test.go +++ b/cloud/blueprint_test.go @@ -24,7 +24,7 @@ func TestBlueprint(t *testing.T) { jsii.String("Custom::LogRetention"): jsii.Number(1), } - template := assertions.Template_FromStack(stack) + template := assertions.Template_FromStack(stack, nil) for key, val := range require { template.ResourceCountIs(key, val) } diff --git a/cmd/lambda/petshop/main.go b/cmd/lambda/petshop/main.go new file mode 100644 index 0000000..b84b7be --- /dev/null +++ b/cmd/lambda/petshop/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "github.com/aws/aws-lambda-go/lambda" + "github.com/fogfish/blueprint-serverless-golang/cmd" + httpd "github.com/fogfish/gouldian/v2/server/aws/apigateway" +) + +func main() { + api := cmd.NewPetShopAPI() + + lambda.Start( + httpd.Serve( + api.List(), + api.Create(), + api.Lookup(), + ), + ) +} diff --git a/cmd/lambda/scud/main.go b/cmd/lambda/scud/main.go deleted file mode 100644 index 0970bb6..0000000 --- a/cmd/lambda/scud/main.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import ( - "github.com/aws/aws-lambda-go/lambda" - "github.com/fogfish/blueprint-serverless-golang/http" - µ "github.com/fogfish/gouldian/server/aws/apigateway" -) - -func main() { - api := http.ServiceScud{} - - lambda.Start( - µ.Serve( - api.Lookup(), - ), - ) -} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..ed03640 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,20 @@ +package main + +import ( + server "net/http" + + "github.com/fogfish/blueprint-serverless-golang/cmd" + "github.com/fogfish/gouldian/v2/server/httpd" +) + +func main() { + api := cmd.NewPetShopAPI() + + server.ListenAndServe(":8080", + httpd.Serve( + api.List(), + api.Create(), + api.Lookup(), + ), + ) +} diff --git a/cmd/service.go b/cmd/service.go new file mode 100644 index 0000000..e4cc84e --- /dev/null +++ b/cmd/service.go @@ -0,0 +1,21 @@ +package cmd + +import ( + core "github.com/fogfish/blueprint-serverless-golang" + "github.com/fogfish/blueprint-serverless-golang/http" + "github.com/fogfish/blueprint-serverless-golang/internal/mock" + "github.com/fogfish/blueprint-serverless-golang/internal/services/pets" + cache "github.com/fogfish/blueprint-serverless-golang/internal/storage" +) + +func NewPetShopAPI() *http.PetShopAPI { + storePets := cache.New[core.Identity, core.Pet]() + storePrice := cache.New[core.Category, core.Price]() + + fetcher := pets.NewFetcher(storePets, storePrice) + creator := pets.NewCreator(storePets, storePrice) + + mock.SetupCreatorWithPets(creator) + + return http.NewPetShopAPI(fetcher, creator) +} diff --git a/go.mod b/go.mod index 59cd858..f4a46df 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,41 @@ module github.com/fogfish/blueprint-serverless-golang -go 1.18 +go 1.20 require ( github.com/ajg/form v1.5.2-0.20200323032839-9aeb3cf462e1 // indirect - github.com/aws/aws-cdk-go/awscdk/v2 v2.29.1 - github.com/aws/aws-lambda-go v1.32.1 - github.com/aws/constructs-go/constructs/v10 v10.1.43 - github.com/aws/jsii-runtime-go v1.61.0 - github.com/fogfish/gouldian v1.6.0 - github.com/fogfish/guid v1.0.0 // indirect - github.com/fogfish/it v0.9.1 - github.com/fogfish/scud v0.4.16 + github.com/aws/aws-cdk-go/awscdk/v2 v2.67.0 + github.com/aws/aws-lambda-go v1.38.0 + github.com/aws/constructs-go/constructs/v10 v10.1.270 + github.com/aws/jsii-runtime-go v1.77.0 + github.com/fogfish/it v1.0.0 // indirect + github.com/fogfish/scud v0.6.0 ) require ( - github.com/Masterminds/semver/v3 v3.1.1 // indirect - github.com/fogfish/golem v0.8.5 // indirect + github.com/fogfish/faults v0.2.0 + github.com/fogfish/gouldian/v2 v2.0.1 + github.com/fogfish/guid/v2 v2.0.2 + github.com/fogfish/gurl/v2 v2.5.0 + github.com/fogfish/it/v2 v2.0.1 + github.com/fogfish/schemaorg v1.14.1 + github.com/fogfish/skiplist v0.9.2 + github.com/golang/mock v1.6.0 +) + +require ( + github.com/Masterminds/semver/v3 v3.2.0 // indirect + github.com/cdklabs/awscdk-asset-awscli-go/awscliv1/v2 v2.2.97 // indirect + github.com/cdklabs/awscdk-asset-kubectl-go/kubectlv20/v2 v2.1.1 // indirect + github.com/cdklabs/awscdk-asset-node-proxy-agent-go/nodeproxyagentv5/v2 v2.0.77 // indirect + github.com/fogfish/golem v0.9.2 // indirect + github.com/fogfish/logger v1.1.0 // indirect + github.com/google/go-cmp v0.5.9 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/yuin/goldmark v1.5.4 // indirect + golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect + golang.org/x/mod v0.9.0 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/sys v0.6.0 // indirect + golang.org/x/tools v0.7.0 // indirect ) diff --git a/go.sum b/go.sum index b64d03b..d78ef76 100644 --- a/go.sum +++ b/go.sum @@ -1,39 +1,90 @@ -github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/ajg/form v1.5.2-0.20200323032839-9aeb3cf462e1 h1:8Qzi+0Uch1VJvdrOhJ8U8FqoPLbUdETPgMqGJ6DSMSQ= github.com/ajg/form v1.5.2-0.20200323032839-9aeb3cf462e1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= -github.com/aws/aws-cdk-go/awscdk/v2 v2.29.1 h1:OVlD924ncu8lMWt8jQsTktzcrwFTjleqeaUdg8Gp9M0= -github.com/aws/aws-cdk-go/awscdk/v2 v2.29.1/go.mod h1:EY8h/zcTgdfcg4YCjraAvbKnT8YWplL8mLQutTnIj+M= -github.com/aws/aws-lambda-go v1.32.1 h1:ls0FU8Mt7ayJszb945zFkUfzxhkQTli8mpJstVcDtCY= -github.com/aws/aws-lambda-go v1.32.1/go.mod h1:jwFe2KmMsHmffA1X2R09hH6lFzJQxzI8qK17ewzbQMM= -github.com/aws/constructs-go/constructs/v10 v10.1.33/go.mod h1:qbIG6z4lreP1aYvcZjAGx6fGTSAZtDdeo5p8XPWbMb0= -github.com/aws/constructs-go/constructs/v10 v10.1.43 h1:3cUr7zHzund4vwCLsnwludODa+SCF9L+QXzke+ltYzs= -github.com/aws/constructs-go/constructs/v10 v10.1.43/go.mod h1:bzi/H67zKyeYqPtYwukrmY4fqIvNOE9Ts9QsnXaahiM= -github.com/aws/jsii-runtime-go v1.60.0/go.mod h1:OPeobFzUctDjq8EXbRZbIphpzQg3lzMs8KH09xuHyk0= -github.com/aws/jsii-runtime-go v1.60.1/go.mod h1:OPeobFzUctDjq8EXbRZbIphpzQg3lzMs8KH09xuHyk0= -github.com/aws/jsii-runtime-go v1.61.0 h1:mK3dLs+KYSsFL8tx3/WbZ3Mi9xT+M4l8C41J7YBr9/4= -github.com/aws/jsii-runtime-go v1.61.0/go.mod h1:YUEgRT35HJwwzAHWLnjekEXHfyxNaEKESsU8XUlVeCs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/aws/aws-cdk-go/awscdk/v2 v2.67.0 h1:fKxY8oYygqp5rScwd6SaPLVM85uK3WrFDOcknn/zo3Q= +github.com/aws/aws-cdk-go/awscdk/v2 v2.67.0/go.mod h1:VUgy7k4jFLyep9Mm4f1aKLpji2w5nQs7mxDrw49SN40= +github.com/aws/aws-lambda-go v1.38.0 h1:4CUdxGzvuQp0o8Zh7KtupB9XvCiiY8yKqJtzco+gsDw= +github.com/aws/aws-lambda-go v1.38.0/go.mod h1:jwFe2KmMsHmffA1X2R09hH6lFzJQxzI8qK17ewzbQMM= +github.com/aws/constructs-go/constructs/v10 v10.1.270 h1:MDjG2YpaauSCjl230HLRo+AD6vNclPv148zkFEn4cSc= +github.com/aws/constructs-go/constructs/v10 v10.1.270/go.mod h1:0kRYiOOoiEo4YOzVYzonyhdYs78G89qLl0YeasyIONA= +github.com/aws/jsii-runtime-go v1.77.0 h1:jhb0H395us+QzIG3yUuzgoP63cL2YQPnSlMQ572WoYM= +github.com/aws/jsii-runtime-go v1.77.0/go.mod h1:1YWJ9VJ3bwe03Nsq2rsGFA0uQIiJZo0FEKfxK6j7cGg= +github.com/cdklabs/awscdk-asset-awscli-go/awscliv1/v2 v2.2.97 h1:djh/IxEOenTcd3r5PqdI/oG+0DejpcDFgc7YzCjVQW4= +github.com/cdklabs/awscdk-asset-awscli-go/awscliv1/v2 v2.2.97/go.mod h1:PkuOc2PJS/vvkezj7ROedaZ9RrIH6BFy07izhAn4ZQ8= +github.com/cdklabs/awscdk-asset-kubectl-go/kubectlv20/v2 v2.1.1 h1:l5N27aCCjAB5cgW5pI4/ujnasPL8hUcJ9KBxrKk6UiQ= +github.com/cdklabs/awscdk-asset-kubectl-go/kubectlv20/v2 v2.1.1/go.mod h1:CvFHBo0qcg8LUkJqIxQtP1rD/sNGv9bX3L2vHT2FUAo= +github.com/cdklabs/awscdk-asset-node-proxy-agent-go/nodeproxyagentv5/v2 v2.0.77 h1:Dz48ATZZyiWfGc93tUyCZh7Aoquno5G7g/azPYnlRdI= +github.com/cdklabs/awscdk-asset-node-proxy-agent-go/nodeproxyagentv5/v2 v2.0.77/go.mod h1:xuNRPgwJuKObjPrOjEI7kv7A0Z8F1lNiwSdCEFJQfMc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fogfish/golem v0.8.5 h1:ILBc28VTz2H2k18xC+1dbhrk4Y9iBCxN1o2iG8lEoBA= -github.com/fogfish/golem v0.8.5/go.mod h1:cQDsAMsur65kwXT/X1FcGRHrQaaQ+5WA87aWjm4Fnuk= -github.com/fogfish/gouldian v1.6.0 h1:sKz6fxQOkH3+M40EyqUCh9TEI3bO1N9jZ52QcQraNA8= -github.com/fogfish/gouldian v1.6.0/go.mod h1:sUFLfL7AiG3t4/VddyKVdPVh6DONrdz3BxF66mV3BYs= -github.com/fogfish/guid v1.0.0 h1:Wf19/7IWFAQvuvfJDEc/Jr47OzFm+Mk+zHJnvo6S31k= -github.com/fogfish/guid v1.0.0/go.mod h1:5iZJAJEepH9rXpnNOi+UYIIzjX57yQqMpXF6K/nKEEg= -github.com/fogfish/it v0.9.1 h1:Pu+qgqBV2ilZDzZzPIbUIhMIkdpHgbGUsdEwVQvBxNQ= +github.com/fogfish/faults v0.2.0 h1:3KHvZN3cgv2omAGw0MCVH/AbrqxfNag+TFGpgUp6m1w= +github.com/fogfish/faults v0.2.0/go.mod h1:PtvzLt9TP4IF/hRkwRp4dZub42oaMrLbxdS6vmSCJOs= +github.com/fogfish/golem v0.9.2 h1:tESSlzo+wih7fLfLFFegKOU8l45Ew9A8a9JU84rGtGY= +github.com/fogfish/golem v0.9.2/go.mod h1:lj7cttUvvjAE4KZU4RzHHw0o2pYozkPqat5ff38u71c= +github.com/fogfish/gouldian/v2 v2.0.1 h1:7rAtSjv/31SRf+dROiTFmsF9UmQysSega54BFq50Yhw= +github.com/fogfish/gouldian/v2 v2.0.1/go.mod h1:m5lLg+DvIFucQ7rzFAWsZ5ZtIuSmnCNBoP3HY+Pncds= +github.com/fogfish/guid/v2 v2.0.2 h1:apsRAnSTkft8izOvLipUstHtWYDmVum7kcunYTR5Kv8= +github.com/fogfish/guid/v2 v2.0.2/go.mod h1:KkZ5T4EE3BqWQJFZBPLSHV/tBe23Xq4KvuPfwtNtepU= +github.com/fogfish/gurl/v2 v2.5.0 h1:S60X6w7mpPkiGL2kbBUgEh7U2o75hfKt5Bv3YnQxgtM= +github.com/fogfish/gurl/v2 v2.5.0/go.mod h1:MMSAHe+Vo+EhJZ6iH1CYeKw64T11G/NAUPK0cZ2RyS4= github.com/fogfish/it v0.9.1/go.mod h1:NQJG4Ygvek85y7zGj0Gny8+6ygAnHjfBORhI7TdQhp4= -github.com/fogfish/scud v0.4.16 h1:P6JbfIk+nxXx6FIDTWJlDkACcYrP3U29iLHL8o8X9ws= -github.com/fogfish/scud v0.4.16/go.mod h1:3wIpV7GZvc58lkLngiGJThZ/a4L04baFuWBqf9Q4xFY= +github.com/fogfish/it v1.0.0 h1:kiwFHZcrkRLUydZoIoY0gTuMfj38trwvLo0YRyIkeG8= +github.com/fogfish/it v1.0.0/go.mod h1:NQJG4Ygvek85y7zGj0Gny8+6ygAnHjfBORhI7TdQhp4= +github.com/fogfish/it/v2 v2.0.1 h1:vu3kV2xzYDPHoMHMABxXeu5CoMcTfRc4gkWkzOUkRJY= +github.com/fogfish/it/v2 v2.0.1/go.mod h1:h5FdKaEQT4sUEykiVkB8VV4jX27XabFVeWhoDZaRZtE= +github.com/fogfish/logger v1.1.0 h1:IZncrh4+Q7yyngcIq8lBr2VwcYE2huXcgCPKcT+zPOY= +github.com/fogfish/logger v1.1.0/go.mod h1:o9Url3zZMjg87bOJKZTdWIX3aRmClY+3Ld97Jc2Podg= +github.com/fogfish/schemaorg v1.14.1 h1:HItbmxuaoD/tgylPyfIop+8ebJPKSDgmCLeOrYrVy4c= +github.com/fogfish/schemaorg v1.14.1/go.mod h1:CDOmEVSdag/o66Y3qjFROm0mUjJxDvSzAOXQwd+ZFrs= +github.com/fogfish/scud v0.6.0 h1:sJsWAvvRcX4kRYYUXbOTw9hyZV+ax01TxpXlHKeTJGg= +github.com/fogfish/scud v0.6.0/go.mod h1:7EH9GAGQK4oux9sTMhtSEfEVbism2ED+2gTb/UNFqvs= +github.com/fogfish/skiplist v0.9.2 h1:JCJfJqNPDEq5JSQtvJc2B/9h+JCmBLUYYIhRiAyKld0= +github.com/fogfish/skiplist v0.9.2/go.mod h1:tda0SRPKXYyUIGVDrqsJwn6B18oOYmv1xrnQ+VKTHrc= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= +github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= +golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/http/api.go b/http/api.go deleted file mode 100644 index 40033c6..0000000 --- a/http/api.go +++ /dev/null @@ -1,21 +0,0 @@ -package http - -import ( - scud "github.com/fogfish/blueprint-serverless-golang" - µ "github.com/fogfish/gouldian" -) - -// ServiceScud is and example RESTfull Service -type ServiceScud struct{} - -// Lookup is an example implementation of endpoint -func (api ServiceScud) Lookup() µ.Routable { - return µ.GET( - µ.URI(µ.Path("scud")), - func(*µ.Context) error { - return µ.Status.OK( - µ.WithJSON(scud.NewStubs()), - ) - }, - ) -} diff --git a/http/api/pet.go b/http/api/pet.go new file mode 100644 index 0000000..443a768 --- /dev/null +++ b/http/api/pet.go @@ -0,0 +1,54 @@ +package api + +import ( + "fmt" + + core "github.com/fogfish/blueprint-serverless-golang" + "github.com/fogfish/schemaorg" +) + +type Pet struct { + ID schemaorg.Identifier `json:"id"` + Category schemaorg.Category `json:"category"` + Price schemaorg.Price `json:"price"` + Url schemaorg.Url `json:"url"` +} + +func (pet Pet) ToCore() core.Pet { + return core.Pet{ + ID: string(pet.ID), + Category: string(pet.Category), + Price: float64(pet.Price), + } +} + +func NewPet(pet core.Pet) Pet { + return Pet{ + ID: schemaorg.Identifier(pet.ID), + Category: schemaorg.Category(pet.Category), + Price: schemaorg.Price(pet.Price), + Url: schemaorg.Url(fmt.Sprintf("/petshop/pets/%s", pet.ID)), + } +} + +type Pets struct { + Pets []Pet `json:"pets,omitempty"` + Next *schemaorg.Url `json:"next,omitempty"` +} + +func NewPets(size int, pets []core.Pet) Pets { + cursor := "" + if len(pets) > size { + pets, cursor = pets[:size], fmt.Sprintf("/petshop/pets?cursor=%s", pets[size].ID) + } + + seq := make([]Pet, len(pets)) + for i, pet := range pets { + seq[i] = NewPet(pet) + } + + return Pets{ + Pets: seq, + Next: (*schemaorg.Url)(&cursor), + } +} diff --git a/http/api_test.go b/http/api_test.go deleted file mode 100644 index 20e4712..0000000 --- a/http/api_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package http_test - -import ( - "testing" - - "github.com/fogfish/blueprint-serverless-golang/http" - µ "github.com/fogfish/gouldian" - "github.com/fogfish/gouldian/mock" - "github.com/fogfish/it" -) - -func TestLookup(t *testing.T) { - api := http.ServiceScud{} - - req := mock.Input( - mock.Method("GET"), - mock.URL("/scud"), - ) - - endpoint := mock.Endpoint(api.Lookup()) - expect := µ.Status.OK( - µ.WithHeader("Content-Type", "application/json"), - µ.WithBytes([]byte(`[{"id":"a"},{"id":"b"},{"id":"c"}]`)), - ) - - it.Ok(t). - If(endpoint(req)).Should().Equal(expect) -} diff --git a/http/endpoints.go b/http/endpoints.go new file mode 100644 index 0000000..63de8f0 --- /dev/null +++ b/http/endpoints.go @@ -0,0 +1,21 @@ +package http + +import ( + µ "github.com/fogfish/gouldian/v2" +) + +func AllowSecretCode() µ.Endpoint { + return µ.Authorization( + func(kind, digest string) error { + if kind != "Basic" { + return µ.ErrNoMatch + } + + if digest != "cGV0c3RvcmU6b3duZXIK" { + return µ.ErrNoMatch + } + + return nil + }, + ) +} diff --git a/http/petshop.go b/http/petshop.go new file mode 100644 index 0000000..16d1528 --- /dev/null +++ b/http/petshop.go @@ -0,0 +1,111 @@ +package http + +import ( + "context" + + core "github.com/fogfish/blueprint-serverless-golang" + "github.com/fogfish/blueprint-serverless-golang/http/api" + "github.com/fogfish/faults" + µ "github.com/fogfish/gouldian/v2" + ø "github.com/fogfish/gouldian/v2/output" + "github.com/fogfish/schemaorg" +) + +//go:generate mockgen -destination ../mock/petshop.go -package mock . PetFetcher,PetCreator + +type PetFetcher interface { + LookupPet(context.Context, core.Identity) (core.Pet, error) + LookupPetsAfterKey(context.Context, core.Identity, int) ([]core.Pet, error) +} + +type PetCreator interface { + CreatePet(context.Context, core.Pet) error +} + +type PetShopAPI struct { + fetcher PetFetcher + creator PetCreator +} + +func NewPetShopAPI(fetcher PetFetcher, creator PetCreator) *PetShopAPI { + return &PetShopAPI{ + fetcher: fetcher, + creator: creator, + } +} + +type reqPetShop struct { + ID schemaorg.Identifier + Pet api.Pet +} + +var ( + reqID = µ.Optics1[reqPetShop, schemaorg.Identifier]() + reqPet = µ.Optics1[reqPetShop, api.Pet]() + petshop = µ.Path("petshop") + pets = µ.Path("pets") + petID = µ.Path(reqID) + petSeqN = 3 +) + +func (shop PetShopAPI) List() µ.Routable { + return µ.GET( + µ.URI(petshop, pets), + µ.ParamMaybe("cursor", reqID), + µ.Accept.ApplicationJSON, + µ.FMap(func(ctx *µ.Context, req *reqPetShop) error { + seq, err := shop.fetcher.LookupPetsAfterKey(ctx, string(req.ID), petSeqN+1) + switch { + case err == nil: + pets := api.NewPets(petSeqN, seq) + return ø.Status.OK( + ø.ContentType.ApplicationJSON, + // ø.L + ø.Send(pets), + ) + default: + return ø.Status.InternalServerError(ø.Error(err)) + } + }), + ) +} + +func (shop PetShopAPI) Create() µ.Routable { + return µ.POST( + µ.URI(petshop, pets), + AllowSecretCode(), + µ.ContentType.ApplicationJSON, + µ.Body(reqPet), + µ.FMap(func(ctx *µ.Context, req *reqPetShop) error { + pet := req.Pet.ToCore() + err := shop.creator.CreatePet(ctx, pet) + switch { + case err == nil: + return ø.Status.Created() + default: + return ø.Status.InternalServerError(ø.Error(err)) + } + }), + ) +} + +func (shop PetShopAPI) Lookup() µ.Routable { + return µ.GET( + µ.URI(petshop, pets, petID), + µ.Accept.ApplicationJSON, + µ.FMap(func(ctx *µ.Context, req *reqPetShop) error { + pet, err := shop.fetcher.LookupPet(ctx, string(req.ID)) + switch { + case err == nil: + return ø.Status.OK( + ø.ContentType.ApplicationJSON, + ø.Send(api.NewPet(pet)), + ) + case faults.IsNotFound(err): + return ø.Status.NotFound(ø.Error(err)) + default: + return ø.Status.InternalServerError(ø.Error(err)) + } + }), + ) +} diff --git a/http/petshop_test.go b/http/petshop_test.go new file mode 100644 index 0000000..3e2a228 --- /dev/null +++ b/http/petshop_test.go @@ -0,0 +1,161 @@ +package http_test + +import ( + "errors" + "testing" + + "github.com/fogfish/blueprint-serverless-golang/http" + "github.com/fogfish/blueprint-serverless-golang/http/api" + "github.com/fogfish/blueprint-serverless-golang/internal/mock" + µ "github.com/fogfish/gouldian/v2" + µmock "github.com/fogfish/gouldian/v2/mock" + ø "github.com/fogfish/gouldian/v2/output" + "github.com/fogfish/guid/v2" + "github.com/fogfish/it/v2" + "github.com/golang/mock/gomock" +) + +func init() { + µ.Sequence = guid.NewClockMock() +} + +func TestPetShopList(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mpf := mock.NewMockPetFetcher(ctrl) + mpf.EXPECT().LookupPetsAfterKey( + gomock.Any(), + gomock.Eq(""), + gomock.Any(), + ).Return(mock.Pets[0:1], nil) + + service := http.NewPetShopAPI(mpf, nil) + httpd := µmock.Endpoint(service.List()) + yield := httpd(µmock.Input( + µmock.Method("GET"), + µmock.URL("/petshop/pets"), + µmock.Header("Accept", "application/json"), + )) + + it.Then(t).Should( + it.Equiv(yield, + ø.Status.OK( + ø.ContentType.ApplicationJSON, + ø.Send(api.NewPets(2, mock.Pets[0:1])), + ), + ), + ) +} + +func TestPetShopListWithCursor(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mpf := mock.NewMockPetFetcher(ctrl) + mpf.EXPECT().LookupPetsAfterKey( + gomock.Any(), + gomock.Eq("A02"), + gomock.Any(), + ).Return(mock.Pets[0:1], nil) + + service := http.NewPetShopAPI(mpf, nil) + httpd := µmock.Endpoint(service.List()) + yield := httpd(µmock.Input( + µmock.Method("GET"), + µmock.URL("/petshop/pets?cursor=A02"), + µmock.Header("Accept", "application/json"), + )) + + it.Then(t).Should( + it.Equiv(yield, + ø.Status.OK( + ø.ContentType.ApplicationJSON, + ø.Send(api.NewPets(2, mock.Pets[0:1])), + ), + ), + ) +} + +func TestPetShopListFailed(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mpf := mock.NewMockPetFetcher(ctrl) + mpf.EXPECT().LookupPetsAfterKey( + gomock.Any(), + gomock.Eq(""), + gomock.Any(), + ).Return(nil, errors.New("fault")) + + service := http.NewPetShopAPI(mpf, nil) + httpd := µmock.Endpoint(service.List()) + yield := httpd(µmock.Input( + µmock.Method("GET"), + µmock.URL("/petshop/pets"), + µmock.Header("Accept", "application/json"), + )) + + it.Then(t).Should( + it.Equiv(yield, + ø.Status.InternalServerError( + ø.Error(errors.New("fault")), + ), + ), + ) +} + +func TestPetShopLookup(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mpf := mock.NewMockPetFetcher(ctrl) + mpf.EXPECT().LookupPet( + gomock.Any(), + gomock.Eq("A01"), + ).Return(mock.Pets[0], nil) + + service := http.NewPetShopAPI(mpf, nil) + httpd := µmock.Endpoint(service.Lookup()) + yield := httpd(µmock.Input( + µmock.Method("GET"), + µmock.URL("/petshop/pets/A01"), + µmock.Header("Accept", "application/json"), + )) + + it.Then(t).Should( + it.Equiv(yield, + ø.Status.OK( + ø.ContentType.ApplicationJSON, + ø.Send(api.NewPet(mock.Pets[0])), + ), + ), + ) +} + +func TestPetShopCreate(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mpc := mock.NewMockPetCreator(ctrl) + mpc.EXPECT().CreatePet( + gomock.Any(), + gomock.Eq(mock.Pets[0]), + ).Return(nil) + + service := http.NewPetShopAPI(nil, mpc) + httpd := µmock.Endpoint(service.Create()) + yield := httpd(µmock.Input( + µmock.Method("POST"), + µmock.URL("/petshop/pets"), + µmock.Header("Accept", "application/json"), + µmock.Header("Authorization", "Basic cGV0c3RvcmU6b3duZXIK"), + µmock.JSON(mock.Pets[0]), + )) + + it.Then(t).Should( + it.Equiv(yield, + ø.Status.Created(), + ), + ) +} diff --git a/http/suites/assay-it/runner.go b/http/suites/assay-it/runner.go new file mode 100644 index 0000000..8fe60b5 --- /dev/null +++ b/http/suites/assay-it/runner.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + "github.com/fogfish/gurl/v2/http" + suites "github.com/fogfish/blueprint-serverless-golang/http/suites" +) + +func main() { + http.WriteOnce(os.Stdout, http.New(http.WithMemento(), http.WithDefaultHost(os.Args[1])), suites.TestPetShopList, suites.TestPetShopListWithCursor, suites.TestPetShopLookup, suites.TestPetShopCreate, suites.TestPetShopCreateUnauthorized) +} diff --git a/http/suites/petshop.go b/http/suites/petshop.go new file mode 100644 index 0000000..ff1171d --- /dev/null +++ b/http/suites/petshop.go @@ -0,0 +1,56 @@ +package suites + +import ( + "github.com/fogfish/blueprint-serverless-golang/http/api" + "github.com/fogfish/blueprint-serverless-golang/internal/mock" + "github.com/fogfish/gurl/v2/http" + ƒ "github.com/fogfish/gurl/v2/http/recv" + ø "github.com/fogfish/gurl/v2/http/send" +) + +func TestPetShopList() http.Arrow { + return http.GET( + ø.URI("/petshop/pets"), + ø.Accept.ApplicationJSON, + ƒ.Status.OK, + ƒ.Expect(api.NewPets(3, mock.Pets[0:4])), + ) +} + +func TestPetShopListWithCursor() http.Arrow { + return http.GET( + ø.URI("/petshop/pets"), + ø.Param("cursor", mock.Pets[4].ID), + ø.Accept.ApplicationJSON, + ƒ.Status.OK, + ƒ.Expect(api.NewPets(3, mock.Pets[4:8])), + ) +} + +func TestPetShopLookup() http.Arrow { + return http.GET( + ø.URI("/petshop/pets/"+mock.Pets[16].ID), + ø.Accept.ApplicationJSON, + ƒ.Status.OK, + ƒ.Expect(api.NewPet(mock.Pets[16])), + ) +} + +func TestPetShopCreate() http.Arrow { + return http.POST( + ø.URI("/petshop/pets"), + ø.Authorization.Set("Basic cGV0c3RvcmU6b3duZXIK"), + ø.ContentType.ApplicationJSON, + ø.Send(mock.Pets[16]), + ƒ.Status.Created, + ) +} + +func TestPetShopCreateUnauthorized() http.Arrow { + return http.POST( + ø.URI("/petshop/pets"), + ø.ContentType.ApplicationJSON, + ø.Send(mock.Pets[16]), + ƒ.Status.Unauthorized, + ) +} diff --git a/internal/mock/pets.go b/internal/mock/pets.go new file mode 100644 index 0000000..dbfc932 --- /dev/null +++ b/internal/mock/pets.go @@ -0,0 +1,40 @@ +package mock + +import ( + "context" + + core "github.com/fogfish/blueprint-serverless-golang" +) + +var Pets = []core.Pet{ + {ID: "A01", Category: "elk", Price: 12249.99}, + {ID: "A02", Category: "ant", Price: 0.99}, + {ID: "A03", Category: "cow", Price: 3449.99}, + {ID: "A04", Category: "pig", Price: 749.99}, + {ID: "A05", Category: "dog", Price: 249.99}, + {ID: "A06", Category: "cat", Price: 124.99}, + {ID: "A07", Category: "bee", Price: 2.99}, + {ID: "A08", Category: "eel", Price: 49.99}, + {ID: "A09", Category: "owl", Price: 449.99}, + {ID: "A10", Category: "fox", Price: 349.99}, + {ID: "A11", Category: "hen", Price: 19.99}, + {ID: "A12", Category: "bat", Price: 249.99}, + {ID: "A13", Category: "rat", Price: 224.99}, + {ID: "A14", Category: "emu", Price: 924.99}, + {ID: "A15", Category: "gnu", Price: 11449.99}, + {ID: "A16", Category: "ape", Price: 9449.99}, + {ID: "A17", Category: "koi", Price: 74.99}, +} + +type Creator interface { + CreatePet(context.Context, core.Pet) error +} + +func SetupCreatorWithPets(creator Creator) error { + for _, pet := range Pets { + if err := creator.CreatePet(context.Background(), pet); err != nil { + return err + } + } + return nil +} diff --git a/internal/mock/petshop.go b/internal/mock/petshop.go new file mode 100644 index 0000000..61c6b7d --- /dev/null +++ b/internal/mock/petshop.go @@ -0,0 +1,103 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/fogfish/blueprint-serverless-golang/http (interfaces: PetFetcher,PetCreator) + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + core "github.com/fogfish/blueprint-serverless-golang" + gomock "github.com/golang/mock/gomock" +) + +// MockPetFetcher is a mock of PetFetcher interface. +type MockPetFetcher struct { + ctrl *gomock.Controller + recorder *MockPetFetcherMockRecorder +} + +// MockPetFetcherMockRecorder is the mock recorder for MockPetFetcher. +type MockPetFetcherMockRecorder struct { + mock *MockPetFetcher +} + +// NewMockPetFetcher creates a new mock instance. +func NewMockPetFetcher(ctrl *gomock.Controller) *MockPetFetcher { + mock := &MockPetFetcher{ctrl: ctrl} + mock.recorder = &MockPetFetcherMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPetFetcher) EXPECT() *MockPetFetcherMockRecorder { + return m.recorder +} + +// LookupPet mocks base method. +func (m *MockPetFetcher) LookupPet(arg0 context.Context, arg1 string) (core.Pet, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LookupPet", arg0, arg1) + ret0, _ := ret[0].(core.Pet) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LookupPet indicates an expected call of LookupPet. +func (mr *MockPetFetcherMockRecorder) LookupPet(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LookupPet", reflect.TypeOf((*MockPetFetcher)(nil).LookupPet), arg0, arg1) +} + +// LookupPetsAfterKey mocks base method. +func (m *MockPetFetcher) LookupPetsAfterKey(arg0 context.Context, arg1 string, arg2 int) ([]core.Pet, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LookupPetsAfterKey", arg0, arg1, arg2) + ret0, _ := ret[0].([]core.Pet) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LookupPetsAfterKey indicates an expected call of LookupPetsAfterKey. +func (mr *MockPetFetcherMockRecorder) LookupPetsAfterKey(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LookupPetsAfterKey", reflect.TypeOf((*MockPetFetcher)(nil).LookupPetsAfterKey), arg0, arg1, arg2) +} + +// MockPetCreator is a mock of PetCreator interface. +type MockPetCreator struct { + ctrl *gomock.Controller + recorder *MockPetCreatorMockRecorder +} + +// MockPetCreatorMockRecorder is the mock recorder for MockPetCreator. +type MockPetCreatorMockRecorder struct { + mock *MockPetCreator +} + +// NewMockPetCreator creates a new mock instance. +func NewMockPetCreator(ctrl *gomock.Controller) *MockPetCreator { + mock := &MockPetCreator{ctrl: ctrl} + mock.recorder = &MockPetCreatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPetCreator) EXPECT() *MockPetCreatorMockRecorder { + return m.recorder +} + +// CreatePet mocks base method. +func (m *MockPetCreator) CreatePet(arg0 context.Context, arg1 core.Pet) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePet", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreatePet indicates an expected call of CreatePet. +func (mr *MockPetCreatorMockRecorder) CreatePet(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePet", reflect.TypeOf((*MockPetCreator)(nil).CreatePet), arg0, arg1) +} diff --git a/internal/services/pets/creator.go b/internal/services/pets/creator.go new file mode 100644 index 0000000..4d9222b --- /dev/null +++ b/internal/services/pets/creator.go @@ -0,0 +1,43 @@ +package pets + +import ( + "context" + + core "github.com/fogfish/blueprint-serverless-golang" + "github.com/fogfish/faults" +) + +const ( + errCreator = faults.Safe1[core.Identity]("failed to create pet (%s)") +) + +type WriterPet interface { + core.Setter[core.Identity, core.Pet] +} + +type WriterPrice interface { + core.Setter[core.Category, core.Price] +} + +type Creator struct { + pets WriterPet + price WriterPrice +} + +func NewCreator(pets WriterPet, price WriterPrice) Creator { + return Creator{pets: pets, price: price} +} + +func (lib Creator) CreatePet(ctx context.Context, pet core.Pet) error { + err := lib.price.Set(ctx, pet.Category, pet.Price) + if err != nil { + return errCreator.New(err, pet.ID) + } + + err = lib.pets.Set(ctx, pet.ID, pet) + if err != nil { + return errCreator.New(err, pet.ID) + } + + return nil +} diff --git a/internal/services/pets/fetcher.go b/internal/services/pets/fetcher.go new file mode 100644 index 0000000..2604d52 --- /dev/null +++ b/internal/services/pets/fetcher.go @@ -0,0 +1,60 @@ +package pets + +import ( + "context" + + core "github.com/fogfish/blueprint-serverless-golang" + "github.com/fogfish/faults" +) + +const ( + errFetcher = faults.Safe1[core.Identity]("failed to fetch pet (%s)") +) + +type ReaderPet interface { + core.Getter[core.Identity, core.Pet] + core.GetterSeq[core.Identity, core.Pet] +} + +type ReaderPrice interface { + core.Getter[core.Category, core.Price] +} + +type Fetcher struct { + pets ReaderPet + price ReaderPrice +} + +func NewFetcher(pets ReaderPet, price ReaderPrice) Fetcher { + return Fetcher{pets: pets, price: price} +} + +func (lib Fetcher) LookupPet(ctx context.Context, key core.Identity) (core.Pet, error) { + pet, err := lib.pets.Get(ctx, key) + if err != nil { + return core.Pet{}, errFetcher.New(err, key) + } + + pet.Price, err = lib.price.Get(ctx, pet.Category) + if err != nil { + return core.Pet{}, errFetcher.New(err, key) + } + + return pet, nil +} + +func (lib Fetcher) LookupPetsAfterKey(ctx context.Context, afterKey core.Identity, n int) ([]core.Pet, error) { + pets, err := lib.pets.Seq(ctx, afterKey, n) + if err != nil { + return nil, errFetcher.New(err, afterKey) + } + + for i := 0; i < len(pets); i++ { + pets[i].Price, err = lib.price.Get(ctx, pets[i].Category) + if err != nil { + return nil, errFetcher.New(err, afterKey) + } + } + + return pets, nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..6522f5b --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,70 @@ +package cache + +import ( + "context" + "fmt" + "sync" + + "github.com/fogfish/skiplist" + "github.com/fogfish/skiplist/ord" +) + +type Cache[K ord.Comparable, V any] struct { + lock *sync.Mutex + store *skiplist.SkipList[K, V] +} + +func New[K ord.Comparable, V any]() *Cache[K, V] { + return &Cache[K, V]{ + lock: &sync.Mutex{}, + store: skiplist.New[K, V](ord.Type[K]()), + } +} + +func (cache *Cache[K, V]) Get(_ context.Context, key K) (V, error) { + cache.lock.Lock() + defer cache.lock.Unlock() + + val, has := skiplist.Lookup(cache.store, key) + if !has { + return val, errNotFound(fmt.Sprintf("%v", key)) + } + + return val, nil +} + +func (cache *Cache[K, V]) Seq(_ context.Context, afterKey K, size int) ([]V, error) { + cache.lock.Lock() + defer cache.lock.Unlock() + + seq := make([]V, 0) + + _, tail := skiplist.Split(cache.store, afterKey) + if tail == nil { + return seq, nil + } + + for tail.Next() { + _, val := tail.Head() + seq = append(seq, val) + + if len(seq) == size { + return seq, nil + } + } + + return seq, nil +} + +func (cache *Cache[K, V]) Set(_ context.Context, key K, val V) error { + cache.lock.Lock() + defer cache.lock.Unlock() + + skiplist.Put(cache.store, key, val) + return nil +} + +type errNotFound string + +func (key errNotFound) Error() string { return string(key) } +func (key errNotFound) NotFound() string { return string(key) } diff --git a/pet.go b/pet.go new file mode 100644 index 0000000..e523cdf --- /dev/null +++ b/pet.go @@ -0,0 +1,14 @@ +package core + +type Identity = string + +type Category = string + +type Price = float64 + +// Pet is an example domain type +type Pet struct { + ID Identity + Category Category + Price Price +} diff --git a/pet_test.go b/pet_test.go new file mode 100644 index 0000000..263344f --- /dev/null +++ b/pet_test.go @@ -0,0 +1,18 @@ +package core_test + +import ( + "testing" + + core "github.com/fogfish/blueprint-serverless-golang" + "github.com/fogfish/it/v2" +) + +func TestPet(t *testing.T) { + var p core.Pet + + it.Then(t).Should( + it.Equal(p.ID, ""), + it.Equal(p.Category, ""), + it.Equal(p.Price, 0.0), + ) +} diff --git a/storage.go b/storage.go new file mode 100644 index 0000000..52e0d10 --- /dev/null +++ b/storage.go @@ -0,0 +1,20 @@ +package core + +import "context" + +// +// Example definition of data access objects. +// Interfaces abstracts capabilities of the storage layer(s). +// + +type Getter[K, V any] interface { + Get(context.Context, K) (V, error) +} + +type GetterSeq[K, V any] interface { + Seq(context.Context, K, int) ([]V, error) +} + +type Setter[K, V any] interface { + Set(context.Context, K, V) error +} diff --git a/stub.go b/stub.go deleted file mode 100644 index 213e0cb..0000000 --- a/stub.go +++ /dev/null @@ -1,15 +0,0 @@ -package scud - -// Stub is an example domain type -type Stub struct { - ID string `json:"id,omitempty"` -} - -// NewStubs is an example factory -func NewStubs() []Stub { - return []Stub{ - {ID: "a"}, - {ID: "b"}, - {ID: "c"}, - } -} diff --git a/stub_test.go b/stub_test.go deleted file mode 100644 index a48265c..0000000 --- a/stub_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package scud_test - -import ( - "testing" - - scud "github.com/fogfish/blueprint-serverless-golang" - "github.com/fogfish/it" -) - -func TestStub(t *testing.T) { - it.Ok(t).If(scud.NewStubs()).Should().Equal( - []scud.Stub{ - {ID: "a"}, - {ID: "b"}, - {ID: "c"}, - }, - ) -}