From ff789600ca69f36609263151f40a4999051abce4 Mon Sep 17 00:00:00 2001 From: Oleh Semeniuk Date: Tue, 14 Apr 2026 20:37:03 +0200 Subject: [PATCH 1/2] feat: implement Go-based BFF API and integrate custom domain support for CloudFront distribution --- Makefile | 20 +++-- api/README.md | 13 +++ api/cmd/getProductsById/main.go | 11 +++ api/cmd/getProductsList/main.go | 11 +++ api/go.mod | 5 ++ api/go.sum | 10 +++ api/internal/products/handler.go | 34 ++++++++ api/internal/products/model.go | 28 ++++++ api/internal/products/products.json | 58 +++++++++++++ api/internal/response/response.go | 44 ++++++++++ client/src/environments/environment.prod.ts | 4 +- client/src/environments/environment.ts | 4 +- infra/bin/infra.ts | 21 ++++- infra/cdk.context.json | 10 +++ infra/lib/api/deploy-api-stack.ts | 11 +++ infra/lib/api/deployment-service.ts | 94 +++++++++++++++++++++ infra/lib/client/deployment-service.ts | 46 +++++++++- infra/lib/shared/config.ts | 2 + infra/lib/shared/domain-stack.ts | 28 ++++++ infra/lib/shared/domain.ts | 37 ++++++++ 20 files changed, 476 insertions(+), 15 deletions(-) create mode 100644 api/README.md create mode 100644 api/cmd/getProductsById/main.go create mode 100644 api/cmd/getProductsList/main.go create mode 100644 api/go.mod create mode 100644 api/go.sum create mode 100644 api/internal/products/handler.go create mode 100644 api/internal/products/model.go create mode 100644 api/internal/products/products.json create mode 100644 api/internal/response/response.go create mode 100644 infra/cdk.context.json create mode 100644 infra/lib/api/deploy-api-stack.ts create mode 100644 infra/lib/api/deployment-service.ts create mode 100644 infra/lib/shared/config.ts create mode 100644 infra/lib/shared/domain-stack.ts create mode 100644 infra/lib/shared/domain.ts diff --git a/Makefile b/Makefile index 6ef19f47..6d99fe2f 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,20 @@ +GO_BUILD := GOOS=linux GOARCH=arm64 go build -tags lambda.norpc + .PHONY: install install: cd client && npm install -.PHONY: build -build: - cd client && npx ng build - -.PHONY: build-prod -build-prod: +.PHONY: build-ui +build-ui: cd client && npx ng build --configuration production +.PHONY: build-api +build-api: + cd api && \ + $(GO_BUILD) -o ./dist/getProductsList/bootstrap ./cmd/getProductsList/main.go && \ + $(GO_BUILD) -o ./dist/getProductsById/bootstrap ./cmd/getProductsById/main.go + .PHONY: deploy -deploy: build-prod - cd infra && npx cdk deploy $(if $(PROFILE),--profile $(PROFILE),) +deploy: build-ui build-api + cd infra && npx cdk deploy --all --require-approval never $(if $(PROFILE),--profile $(PROFILE),) diff --git a/api/README.md b/api/README.md new file mode 100644 index 00000000..3d92f5a4 --- /dev/null +++ b/api/README.md @@ -0,0 +1,13 @@ +Build: + +``` +GOOS=linux GOARCH=arm64 go build -tags lambda.norpc -o ./dist/getProductsList/bootstrap ./cmd/getProductsList/main.go +GOOS=linux GOARCH=arm64 go build -tags lambda.norpc -o ./dist/getProductsById/bootstrap ./cmd/getProductsById/main.go + +``` + +Deploy: + +``` +cdk deploy DeployAPIStack +``` \ No newline at end of file diff --git a/api/cmd/getProductsById/main.go b/api/cmd/getProductsById/main.go new file mode 100644 index 00000000..5a14f8fc --- /dev/null +++ b/api/cmd/getProductsById/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "aws-practitioner-for-js/internal/products" + + "github.com/aws/aws-lambda-go/lambda" +) + +func main() { + lambda.Start(products.GetProductById) +} diff --git a/api/cmd/getProductsList/main.go b/api/cmd/getProductsList/main.go new file mode 100644 index 00000000..687b450b --- /dev/null +++ b/api/cmd/getProductsList/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "aws-practitioner-for-js/internal/products" + + "github.com/aws/aws-lambda-go/lambda" +) + +func main() { + lambda.Start(products.GetProductList) +} diff --git a/api/go.mod b/api/go.mod new file mode 100644 index 00000000..6a6f9f4a --- /dev/null +++ b/api/go.mod @@ -0,0 +1,5 @@ +module aws-practitioner-for-js + +go 1.26.1 + +require github.com/aws/aws-lambda-go v1.54.0 diff --git a/api/go.sum b/api/go.sum new file mode 100644 index 00000000..1b7095a6 --- /dev/null +++ b/api/go.sum @@ -0,0 +1,10 @@ +github.com/aws/aws-lambda-go v1.54.0 h1:EGYpdyRGF88xszqlGcBewz811mJeRS+maNlLZXFheII= +github.com/aws/aws-lambda-go v1.54.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= +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/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/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +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/api/internal/products/handler.go b/api/internal/products/handler.go new file mode 100644 index 00000000..a5e8fef4 --- /dev/null +++ b/api/internal/products/handler.go @@ -0,0 +1,34 @@ +package products + +import ( + "aws-practitioner-for-js/internal/response" + "context" + "net/http" + + "github.com/aws/aws-lambda-go/events" +) + +func GetProductList(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + products, err := GetProducts() + if err != nil { + return response.ErrInternalServer(), err + } + return response.JSON(http.StatusOK, products) +} + +func GetProductById(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + productId := request.PathParameters["productId"] + + products, err := GetProducts() + if err != nil { + return response.ErrInternalServer(), err + } + + for _, p := range products { + if p.ID == productId { + return response.JSON(http.StatusOK, p) + } + } + + return response.ErrNotFound(), nil +} diff --git a/api/internal/products/model.go b/api/internal/products/model.go new file mode 100644 index 00000000..1baa3b35 --- /dev/null +++ b/api/internal/products/model.go @@ -0,0 +1,28 @@ +package products + +import ( + _ "embed" + "encoding/json" +) + +//go:embed products.json +var productsJSON []byte + +type Product struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Price float64 `json:"price"` + Count int `json:"count"` +} + +func GetProducts() ([]Product, error) { + var products []Product + err := json.Unmarshal(productsJSON, &products) + + if err != nil { + return nil, err + } + + return products, nil +} diff --git a/api/internal/products/products.json b/api/internal/products/products.json new file mode 100644 index 00000000..2377a5b3 --- /dev/null +++ b/api/internal/products/products.json @@ -0,0 +1,58 @@ +[ + { + "count": 4, + "description": "Short Product Description1", + "id": "7567ec4b-b10c-48c5-9345-fc73c48a80aa", + "price": 2.4, + "title": "ProductOne" + }, + { + "count": 6, + "description": "Short Product Description3", + "id": "7567ec4b-b10c-48c5-9345-fc73c48a80a0", + "price": 10, + "title": "ProductNew" + }, + { + "count": 7, + "description": "Short Product Description2", + "id": "7567ec4b-b10c-48c5-9345-fc73c48a80a2", + "price": 23, + "title": "ProductTop" + }, + { + "count": 12, + "description": "Short Product Description7", + "id": "7567ec4b-b10c-48c5-9345-fc73c48a80a1", + "price": 15, + "title": "ProductTitle" + }, + { + "count": 7, + "description": "Short Product Description2", + "id": "7567ec4b-b10c-48c5-9345-fc73c48a80a3", + "price": 23, + "title": "Product" + }, + { + "count": 8, + "description": "Short Product Description4", + "id": "7567ec4b-b10c-48c5-9345-fc73348a80a1", + "price": 15, + "title": "ProductTest" + }, + { + "count": 2, + "description": "Short Product Descriptio1", + "id": "7567ec4b-b10c-48c5-9445-fc73c48a80a2", + "price": 23, + "title": "Product2" + }, + { + "count": 3, + "description": "Short Product Description7", + "id": "7567ec4b-b10c-45c5-9345-fc73c48a80a1", + "price": 15, + "title": "ProductName" + } +] diff --git a/api/internal/response/response.go b/api/internal/response/response.go new file mode 100644 index 00000000..81dcd1c7 --- /dev/null +++ b/api/internal/response/response.go @@ -0,0 +1,44 @@ +package response + +import ( + "encoding/json" + "net/http" + + "github.com/aws/aws-lambda-go/events" +) + +var corsHeaders = map[string]string{ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token", + "Access-Control-Allow-Methods": "GET,OPTIONS", +} + +func ErrInternalServer() events.APIGatewayProxyResponse { + return events.APIGatewayProxyResponse{ + StatusCode: http.StatusInternalServerError, + Headers: corsHeaders, + Body: `{"error":"internal server error"}`, + } +} + +func ErrNotFound() events.APIGatewayProxyResponse { + return events.APIGatewayProxyResponse{ + StatusCode: http.StatusNotFound, + Headers: corsHeaders, + Body: `{"error":"not found"}`, + } +} + +func JSON(statusCode int, body any) (events.APIGatewayProxyResponse, error) { + b, err := json.Marshal(body) + + if err != nil { + return ErrInternalServer(), nil + } + + return events.APIGatewayProxyResponse{ + StatusCode: statusCode, + Headers: corsHeaders, + Body: string(b), + }, nil +} diff --git a/client/src/environments/environment.prod.ts b/client/src/environments/environment.prod.ts index a3d95f92..4fa8aab2 100644 --- a/client/src/environments/environment.prod.ts +++ b/client/src/environments/environment.prod.ts @@ -6,14 +6,14 @@ export const environment: Config = { product: 'https://.execute-api.eu-west-1.amazonaws.com/dev', order: 'https://.execute-api.eu-west-1.amazonaws.com/dev', import: 'https://.execute-api.eu-west-1.amazonaws.com/dev', - bff: 'https://.execute-api.eu-west-1.amazonaws.com/dev', + bff: 'https://api.shop-angular-cloudfront.tech', cart: 'https://.execute-api.eu-west-1.amazonaws.com/dev', }, apiEndpointsEnabled: { product: false, order: false, import: false, - bff: false, + bff: true, cart: false, }, }; diff --git a/client/src/environments/environment.ts b/client/src/environments/environment.ts index a9a52a5a..0138e2ad 100644 --- a/client/src/environments/environment.ts +++ b/client/src/environments/environment.ts @@ -10,14 +10,14 @@ export const environment: Config = { product: 'https://.execute-api.eu-west-1.amazonaws.com/dev', order: 'https://.execute-api.eu-west-1.amazonaws.com/dev', import: 'https://.execute-api.eu-west-1.amazonaws.com/dev', - bff: 'https://.execute-api.eu-west-1.amazonaws.com/dev', + bff: 'https://api.shop-angular-cloudfront.tech', cart: 'https://.execute-api.eu-west-1.amazonaws.com/dev', }, apiEndpointsEnabled: { product: false, order: false, import: false, - bff: false, + bff: true, cart: false, }, }; diff --git a/infra/bin/infra.ts b/infra/bin/infra.ts index cfd94659..7331653b 100644 --- a/infra/bin/infra.ts +++ b/infra/bin/infra.ts @@ -1,6 +1,25 @@ #!/usr/bin/env node import * as cdk from 'aws-cdk-lib/core'; import { DeployWebAppStack } from '../lib/client/deploy-web-app-stack'; +import { DeployAPIStack } from '../lib/api/deploy-api-stack'; +import { DomainStack } from '../lib/shared/domain-stack'; +import { DOMAIN_NAME } from '../lib/shared/config'; const app = new cdk.App(); -new DeployWebAppStack(app, 'DeployWebAppStack'); + +// fromLookup() needs an explicit region +const env = { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION ?? 'us-east-1', +}; + +// Creates the Route 53 hosted zone. +new DomainStack(app, 'DomainStack', { + env, + domainName: DOMAIN_NAME, +}); + + +new DeployWebAppStack(app, 'DeployWebAppStack', { env }); + +new DeployAPIStack(app, 'DeployAPIStack', { env }); diff --git a/infra/cdk.context.json b/infra/cdk.context.json new file mode 100644 index 00000000..d8b9c587 --- /dev/null +++ b/infra/cdk.context.json @@ -0,0 +1,10 @@ +{ + "hosted-zone:account=217881743440:domainName=shop-angular-cloudfront.fun:region=us-east-1": { + "Id": "/hostedzone/Z0658991REFTU9HBNJFC", + "Name": "shop-angular-cloudfront.fun." + }, + "hosted-zone:account=217881743440:domainName=shop-angular-cloudfront.tech:region=us-east-1": { + "Id": "/hostedzone/Z06865612H2ZXWW4Q1GXU", + "Name": "shop-angular-cloudfront.tech." + } +} diff --git a/infra/lib/api/deploy-api-stack.ts b/infra/lib/api/deploy-api-stack.ts new file mode 100644 index 00000000..9e5219c5 --- /dev/null +++ b/infra/lib/api/deploy-api-stack.ts @@ -0,0 +1,11 @@ +import * as cdk from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; +import { DeploymentService } from './deployment-service'; + +export class DeployAPIStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + new DeploymentService(this, 'deployment-service'); + } +} diff --git a/infra/lib/api/deployment-service.ts b/infra/lib/api/deployment-service.ts new file mode 100644 index 00000000..5f35f411 --- /dev/null +++ b/infra/lib/api/deployment-service.ts @@ -0,0 +1,94 @@ +import { + aws_apigateway, + aws_lambda, + aws_route53, + aws_route53_targets, + CfnOutput, + Duration, +} from "aws-cdk-lib"; +import { Construct } from "constructs"; +import { API_DOMAIN_NAME, DOMAIN_NAME } from "../shared/config"; +import { createDomainResources } from "../shared/domain"; + +const path = './../api/dist'; + +// Lambda default configuration +const LambdaDefaultConfig = { + runtime: aws_lambda.Runtime.PROVIDED_AL2023, + architecture: aws_lambda.Architecture.ARM_64, + memorySize: 1024, + timeout: Duration.seconds(5), + handler: 'bootstrap', +} + +export class DeploymentService extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + + // Create domain resources + const { hostedZone, certificate } = createDomainResources(this, { + domainName: API_DOMAIN_NAME, + }); + + // Get products list Lambda function + const lambdaGetProductsList = new aws_lambda.Function(this, 'get-products-list-lambda', { + code: aws_lambda.Code.fromAsset(`${path}/getProductsList`), + ...LambdaDefaultConfig + }); + + // Get product by ID Lambda function + const lambdaGetProductsById = new aws_lambda.Function(this, 'get-products-by-id-lambda', { + code: aws_lambda.Code.fromAsset(`${path}/getProductsById`), + ...LambdaDefaultConfig + }); + + // Create API Gateway + const api = new aws_apigateway.RestApi(this, "my-api", { + restApiName: "My API Gateway", + description: "This API serves the Lambda functions.", + + // Custom domain name + domainName: { + domainName: API_DOMAIN_NAME, + certificate, + }, + + // CORS configuration + defaultCorsPreflightOptions: { + allowOrigins: ['http://localhost:4200', `https://${DOMAIN_NAME}`], + allowMethods: aws_apigateway.Cors.ALL_METHODS, + allowHeaders: aws_apigateway.Cors.DEFAULT_HEADERS, + }, + }); + + // DNS A record for api subdomain -> API Gateway + new aws_route53.ARecord(this, 'ApiAliasRecord', { + zone: hostedZone, + recordName: 'api', + target: aws_route53.RecordTarget.fromAlias( + new aws_route53_targets.ApiGateway(api), + ), + }); + + // Lambda integrations + const listIntegration = new aws_apigateway.LambdaIntegration(lambdaGetProductsList); + const getByIdIntegration = new aws_apigateway.LambdaIntegration(lambdaGetProductsById); + + // Products resource + const productsResource = api.root.addResource("products"); + productsResource.addMethod('GET', listIntegration); + + const productIdResource = productsResource.addResource("{productId}"); + productIdResource.addMethod('GET', getByIdIntegration); + + // Outputs + new CfnOutput(this, 'ApiEndpoint', { + value: api.url, + }); + + new CfnOutput(this, 'ApiCustomDomain', { + value: `https://${API_DOMAIN_NAME}`, + description: 'The custom API domain URL', + }); + } +} diff --git a/infra/lib/client/deployment-service.ts b/infra/lib/client/deployment-service.ts index 570ff34d..37e2042a 100644 --- a/infra/lib/client/deployment-service.ts +++ b/infra/lib/client/deployment-service.ts @@ -1,5 +1,16 @@ -import { aws_cloudfront, aws_cloudfront_origins, aws_s3, aws_s3_deployment, CfnOutput, RemovalPolicy } from "aws-cdk-lib"; +import { + aws_cloudfront, + aws_cloudfront_origins, + aws_route53, + aws_route53_targets, + aws_s3, + aws_s3_deployment, + CfnOutput, + RemovalPolicy, +} from "aws-cdk-lib"; import { Construct } from "constructs"; +import { DOMAIN_NAME } from "../shared/config"; +import { createDomainResources } from "../shared/domain"; const path = './../client/dist'; @@ -7,6 +18,11 @@ export class DeploymentService extends Construct { constructor(scope: Construct, id: string) { super(scope, id); + const { hostedZone, certificate } = createDomainResources(this, { + domainName: DOMAIN_NAME, + // subjectAlternativeNames: [WWW_DOMAIN_NAME], + }); + const hostingBucket = new aws_s3.Bucket(this, "FrontendBucket", { // Block all public access to the bucket blockPublicAccess: aws_s3.BlockPublicAccess.BLOCK_ALL, @@ -31,6 +47,10 @@ export class DeploymentService extends Construct { viewerProtocolPolicy: aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, }, + // Custom domain and SSL certificate + domainNames: [DOMAIN_NAME], + certificate, + // Set the default root object to index.html defaultRootObject: 'index.html', @@ -45,6 +65,23 @@ export class DeploymentService extends Construct { } ); + // DNS A record for root domain -> CloudFront + new aws_route53.ARecord(this, 'AliasRecord', { + zone: hostedZone, + target: aws_route53.RecordTarget.fromAlias( + new aws_route53_targets.CloudFrontTarget(distribution), + ), + }); + + // // DNS A record for www -> CloudFront + // new aws_route53.ARecord(this, 'WwwAliasRecord', { + // zone: hostedZone, + // recordName: 'www', + // target: aws_route53.RecordTarget.fromAlias( + // new aws_route53_targets.CloudFrontTarget(distribution), + // ), + // }); + new aws_s3_deployment.BucketDeployment(this, 'BucketDeployment', { // Deploy the contents of the 'path' directory to the S3 bucket sources: [aws_s3_deployment.Source.asset(path)], @@ -73,5 +110,10 @@ export class DeploymentService extends Construct { exportName: 'BucketName', }); + // Output the custom domain + new CfnOutput(this, 'DomainName', { + value: `https://${DOMAIN_NAME}`, + description: 'The custom domain URL', + }); } -} \ No newline at end of file +} diff --git a/infra/lib/shared/config.ts b/infra/lib/shared/config.ts new file mode 100644 index 00000000..ae12dc2a --- /dev/null +++ b/infra/lib/shared/config.ts @@ -0,0 +1,2 @@ +export const DOMAIN_NAME = 'shop-angular-cloudfront.tech'; +export const API_DOMAIN_NAME = `api.${DOMAIN_NAME}`; diff --git a/infra/lib/shared/domain-stack.ts b/infra/lib/shared/domain-stack.ts new file mode 100644 index 00000000..b3893088 --- /dev/null +++ b/infra/lib/shared/domain-stack.ts @@ -0,0 +1,28 @@ +import * as cdk from 'aws-cdk-lib/core'; +import { aws_route53, CfnOutput } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; + +interface DomainStackProps extends cdk.StackProps { + domainName: string; +} + +/** + * Creates the Route 53 hosted zone for the project's domain. + */ +export class DomainStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: DomainStackProps) { + super(scope, id, props); + + // Creates the Route 53 hosted zone. + const hostedZone = new aws_route53.HostedZone(this, 'HostedZone', { + // The domain name for which to create the hosted zone. + zoneName: props.domainName, + }); + + // Outputs the name servers for the hosted zone. + new CfnOutput(this, 'NameServers', { + value: cdk.Fn.join(', ', hostedZone.hostedZoneNameServers ?? []), + description: 'Set these nameservers at your domain registrar', + }); + } +} diff --git a/infra/lib/shared/domain.ts b/infra/lib/shared/domain.ts new file mode 100644 index 00000000..f60b516a --- /dev/null +++ b/infra/lib/shared/domain.ts @@ -0,0 +1,37 @@ +import { aws_certificatemanager, aws_route53 } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import { DOMAIN_NAME } from './config'; + +interface Resources { + hostedZone: aws_route53.IHostedZone; + certificate: aws_certificatemanager.ICertificate; +} + +interface Options { + /** Primary domain for the certificate */ + domainName: string; + /** Optional alternative names */ + subjectAlternativeNames?: string[]; +} + +/** + * Looks up the project's hosted zone and creates an ACM certificate for it. + */ +export function createDomainResources( + scope: Construct, + options: Options, +): Resources { + // Find hosted zone + const hostedZone = aws_route53.HostedZone.fromLookup(scope, 'HostedZone', { + domainName: DOMAIN_NAME, + }); + + // Create certificate + const certificate = new aws_certificatemanager.Certificate(scope, 'Certificate', { + domainName: options.domainName, + subjectAlternativeNames: options.subjectAlternativeNames, + validation: aws_certificatemanager.CertificateValidation.fromDns(hostedZone), + }); + + return { hostedZone, certificate }; +} From 8b30fa77f34aeaa014e1c66c9a8ddccb35246a8e Mon Sep 17 00:00:00 2001 From: Oleh Semeniuk Date: Tue, 14 Apr 2026 20:52:33 +0200 Subject: [PATCH 2/2] feat: add unit tests for product handlers and define API documentation in Swagger spec --- api/docs/swagger.yaml | 103 +++++++++++++++++++++++++ api/internal/products/handler_test.go | 94 ++++++++++++++++++++++ infra/lib/client/deployment-service.ts | 9 --- 3 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 api/docs/swagger.yaml create mode 100644 api/internal/products/handler_test.go diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml new file mode 100644 index 00000000..f5382c50 --- /dev/null +++ b/api/docs/swagger.yaml @@ -0,0 +1,103 @@ +openapi: 3.0.0 +info: + title: Product Service API + description: API for managing products in the shop application + version: 1.0.0 + +servers: + - url: https://api.shop-angular-cloudfront.tech + description: Production + - url: http://localhost:4200 + description: Local development + +paths: + /products: + get: + summary: Get all products + description: Returns a list of all available products + operationId: getProductsList + responses: + "200": + description: A list of products + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Product" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /products/{productId}: + get: + summary: Get product by ID + description: Returns a single product by its ID + operationId: getProductById + parameters: + - name: productId + in: path + required: true + description: The product ID (UUID) + schema: + type: string + format: uuid + example: 7567ec4b-b10c-48c5-9345-fc73c48a80aa + responses: + "200": + description: Product found + content: + application/json: + schema: + $ref: "#/components/schemas/Product" + "404": + description: Product not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + +components: + schemas: + Product: + type: object + required: + - id + - title + - description + - price + - count + properties: + id: + type: string + format: uuid + example: 7567ec4b-b10c-48c5-9345-fc73c48a80aa + title: + type: string + example: ProductOne + description: + type: string + example: Short Product Description1 + price: + type: number + format: double + example: 2.4 + count: + type: integer + example: 4 + + Error: + type: object + properties: + error: + type: string + example: not found diff --git a/api/internal/products/handler_test.go b/api/internal/products/handler_test.go new file mode 100644 index 00000000..7df47a29 --- /dev/null +++ b/api/internal/products/handler_test.go @@ -0,0 +1,94 @@ +package products + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/aws/aws-lambda-go/events" +) + +func TestGetProductList(t *testing.T) { + ctx := context.Background() + request := events.APIGatewayProxyRequest{} + + response, err := GetProductList(ctx, request) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if response.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", response.StatusCode) + } + + var products []Product + err = json.Unmarshal([]byte(response.Body), &products) + if err != nil { + t.Fatalf("failed to unmarshal response body: %v", err) + } + + if len(products) == 0 { + t.Error("expected non-empty product list") + } +} + +func TestGetProductById_Success(t *testing.T) { + ctx := context.Background() + productId := "7567ec4b-b10c-48c5-9345-fc73c48a80aa" // ProductOne from products.json + request := events.APIGatewayProxyRequest{ + PathParameters: map[string]string{ + "productId": productId, + }, + } + + response, err := GetProductById(ctx, request) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if response.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", response.StatusCode) + } + + var product Product + err = json.Unmarshal([]byte(response.Body), &product) + if err != nil { + t.Fatalf("failed to unmarshal response body: %v", err) + } + + if product.ID != productId { + t.Errorf("expected product ID %s, got %s", productId, product.ID) + } + + if product.Title != "ProductOne" { + t.Errorf("expected product title 'ProductOne', got '%s'", product.Title) + } +} + +func TestGetProductById_NotFound(t *testing.T) { + ctx := context.Background() + productId := "non-existent-id" + request := events.APIGatewayProxyRequest{ + PathParameters: map[string]string{ + "productId": productId, + }, + } + + response, err := GetProductById(ctx, request) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if response.StatusCode != http.StatusNotFound { + t.Errorf("expected status 404, got %d", response.StatusCode) + } + + expectedBody := `{"error":"not found"}` + if response.Body != expectedBody { + t.Errorf("expected body %s, got %s", expectedBody, response.Body) + } +} diff --git a/infra/lib/client/deployment-service.ts b/infra/lib/client/deployment-service.ts index 37e2042a..9addb58e 100644 --- a/infra/lib/client/deployment-service.ts +++ b/infra/lib/client/deployment-service.ts @@ -73,15 +73,6 @@ export class DeploymentService extends Construct { ), }); - // // DNS A record for www -> CloudFront - // new aws_route53.ARecord(this, 'WwwAliasRecord', { - // zone: hostedZone, - // recordName: 'www', - // target: aws_route53.RecordTarget.fromAlias( - // new aws_route53_targets.CloudFrontTarget(distribution), - // ), - // }); - new aws_s3_deployment.BucketDeployment(this, 'BucketDeployment', { // Deploy the contents of the 'path' directory to the S3 bucket sources: [aws_s3_deployment.Source.asset(path)],