diff --git a/capitulo_5/5.9.1/test/handler.test.js b/capitulo_5/5.9.1/test/handler.test.js index f4b9182..af5938e 100644 --- a/capitulo_5/5.9.1/test/handler.test.js +++ b/capitulo_5/5.9.1/test/handler.test.js @@ -13,7 +13,7 @@ const event = { it('#redirect', async () => { const result = await redirect(event) expect(result.statusCode).toBe(301) - expect(result.headers).toEqual({ Location: 'https://novatec.com.br/livros/nodejs-3ed/' }) + expect(result.headers).toEqual({ Location: 'https://www.novatec.com.br/livros/nodejs-3ed/' }) expect(typeof result.body).toBe('string') }) it('#redirect link non existent', async () => { diff --git a/capitulo_5/5.9.2/.nvmrc b/capitulo_5/5.9.2/.nvmrc new file mode 100644 index 0000000..3c03207 --- /dev/null +++ b/capitulo_5/5.9.2/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/capitulo_5/5.9.2/jest.config.js b/capitulo_5/5.9.2/jest.config.js new file mode 100644 index 0000000..b9f744d --- /dev/null +++ b/capitulo_5/5.9.2/jest.config.js @@ -0,0 +1,7 @@ +/** @type {import('jest').Config} */ +const config = { + testEnvironment: 'jest-environment-node', + verbose: true, + setupFiles: ['./test/setup.js'] +} +module.exports = config \ No newline at end of file diff --git a/capitulo_5/5.9.2/package.json b/capitulo_5/5.9.2/package.json new file mode 100644 index 0000000..46a599c --- /dev/null +++ b/capitulo_5/5.9.2/package.json @@ -0,0 +1,28 @@ +{ + "name": "5.9.1", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "export DEBUG=wbruno:*; npx sls dynamodb install --stage local --region sa-east-1; npx sls offline start --stage local --region sa-east-1", + "test": "jest --coverage" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@aws-sdk/client-dynamodb": "3.345.0", + "@aws-sdk/lib-dynamodb": "3.345.0", + "debug": "4.3.4", + "nanoid": "3.3.6" + }, + "devDependencies": { + "aws-sdk-client-mock": "2.1.1", + "dotenv": "16.1.3", + "jest": "29.5.0", + "serverless": "3.32.2", + "serverless-domain-manager": "7.0.4", + "serverless-dynamodb-local": "0.2.40", + "serverless-offline": "12.0.4" + } +} diff --git a/capitulo_5/5.9.2/resources/db/shorts.json b/capitulo_5/5.9.2/resources/db/shorts.json new file mode 100644 index 0000000..f7fc7fe --- /dev/null +++ b/capitulo_5/5.9.2/resources/db/shorts.json @@ -0,0 +1,10 @@ +[ + { + "id": "iNxun1bg5Y", + "link": "https://www.novatec.com.br/livros/nodejs-3ed/" + }, + { + "id": "typescript", + "link": "https://www.novatec.com.br/livros/aprendendo-typescript/" + } +] \ No newline at end of file diff --git a/capitulo_5/5.9.2/resources/serverless.local.yml b/capitulo_5/5.9.2/resources/serverless.local.yml new file mode 100644 index 0000000..224ac09 --- /dev/null +++ b/capitulo_5/5.9.2/resources/serverless.local.yml @@ -0,0 +1,23 @@ +provider: + vpc: + securityGroupIds: + - sg-.. + subnetIds: + - subnet-.. + iam: + role: + name: serverless-${self:service}-${self:provider.stage}-role +resources: + Resources: ${file(resources/table.yml)} +apiGateway: + apiKeys: + - name: localKey + value: d41d8cd98f00b204e9800998ecf8427e +custom: + customDomain: + domainName: localhost +environment: + AWS_NODEJS_CONNECTION_REUSE_ENABLED: 1 + AWS_REGION: sa-east-1 + NODE_ENV: development + DYNAMODB_ENDPOINT: http://localhost:8000 \ No newline at end of file diff --git a/capitulo_5/5.9.2/resources/table.yml b/capitulo_5/5.9.2/resources/table.yml new file mode 100644 index 0000000..96e028c --- /dev/null +++ b/capitulo_5/5.9.2/resources/table.yml @@ -0,0 +1,11 @@ +shortenerTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: Shortener + AttributeDefinitions: + - AttributeName: id + AttributeType: S + KeySchema: + - AttributeName: id + KeyType: HASH + BillingMode: PAY_PER_REQUEST \ No newline at end of file diff --git a/capitulo_5/5.9.2/serverless.yml b/capitulo_5/5.9.2/serverless.yml new file mode 100644 index 0000000..7dec814 --- /dev/null +++ b/capitulo_5/5.9.2/serverless.yml @@ -0,0 +1,116 @@ +service: shortener +plugins: + - serverless-domain-manager + - serverless-dynamodb-local + - serverless-offline #needs to be last in the list +useDotenv: true +configValidationMode: warn +package: + individually: false + excludeDevDependencies: true + patterns: + - node_modules/** + - src/** +provider: + name: aws + architecture: arm64 + region: ${opt:region} + stage: ${opt:stage} + runtime: nodejs18.x + versionFunctions: true + logRetentionInDays: 3 + timeout: 30 + stackName: serverless-${self:service}-${self:provider.stage} + memorySize: 128 + tracing: + apiGateway: true + apiGateway: + apiKeys: ${file(./resources/serverless.${self:provider.stage}.yml):apiGateway.apiKeys, ''} + apiKeySourceType: HEADER + disableDefaultEndpoint: true + stackTags: + Name: ${self:service} + deploymentBucket: + name: algum-s3-${self:provider.stage}-deployment + environment: ${file(./resources/serverless.${self:provider.stage}.yml):environment} + iam: ${file(./resources/serverless.${self:provider.stage}.yml):provider.iam, ''} + logs: + restApi: + accessLogging: false + executionLogging: false +functions: + redirect: + description: redirects hash to given url + handler: src/handler.redirect + events: + - http: + path: /{id} + method: get + cors: true + private: false + byId: + description: retrieve a shortened url by its id + handler: src/shortener.byId + events: + - http: + path: /shorts/{id} + method: get + cors: true + private: true + create: + description: create a new shortened url + handler: src/shortener.create + events: + - http: + path: /shorts + method: post + cors: true + private: true + list: + description: list all shortened urls + handler: src/shortener.list + events: + - http: + path: /shorts + method: get + cors: true + private: true + update: + description: update a new shortened url + handler: src/shortener.update + events: + - http: + path: /shorts/{id} + method: put + cors: true + private: true + remove: + description: remove a new shortened url + handler: src/shortener.remove + events: + - http: + path: /shorts/{id} + method: delete + cors: true + private: true +resources: ${file(./resources/serverless.${self:provider.stage}.yml):resources, ''} +custom: + dynamodb: + stages: + - local + start: + seed: true + migrate: true + seed: + domain: + sources: + - table: Shortener + sources: [./resources/db/shorts.json] + endpoints: + dynamodb-url: 'http://localhost:8000' + customDomain: + enabled: true + domainName: ${self:service}.${self:provider.stage}.seudominio.com.br + stage: ${self:provider.stage} + createRoute53Record: false + basePath: '' \ No newline at end of file diff --git a/capitulo_5/5.9.2/src/config/dynamodb.js b/capitulo_5/5.9.2/src/config/dynamodb.js new file mode 100644 index 0000000..d1734b3 --- /dev/null +++ b/capitulo_5/5.9.2/src/config/dynamodb.js @@ -0,0 +1,15 @@ +const { DynamoDBClient } = require('@aws-sdk/client-dynamodb') +const { DynamoDBDocumentClient } = require('@aws-sdk/lib-dynamodb') + +const marshallOptions = { + removeUndefinedValues: true +} + +const localConfig = { endpoint: process.env.DYNAMODB_ENDPOINT } +/* istanbul ignore next */ +const config = process.env.DYNAMODB_ENDPOINT ? localConfig : {} + +const dynamodb = new DynamoDBClient(config) +const documentClient = DynamoDBDocumentClient.from(dynamodb, { marshallOptions }) + +module.exports = { dynamodb, documentClient } \ No newline at end of file diff --git a/capitulo_5/5.9.2/src/handler.js b/capitulo_5/5.9.2/src/handler.js new file mode 100644 index 0000000..aeba855 --- /dev/null +++ b/capitulo_5/5.9.2/src/handler.js @@ -0,0 +1,27 @@ +const html = ` +301 Moved Permanently + +

301 Moved Permanently

+ +` +const repository = require('./repository') +const redirect = (event) => { + const params = event.pathParameters + + return repository.byId(params.id) + .then(result => { + console.log({ result }) + if (!result?.link) { + throw new Error('not found') + } + return result + }) + .then(result => { + const headers = { Location: result.link } + return { statusCode: 301, headers, body: html } + }) + .catch((err) => { + return { statusCode: 404, body: JSON.stringify(err) } + }) +} +module.exports = { redirect } \ No newline at end of file diff --git a/capitulo_5/5.9.2/src/repository.js b/capitulo_5/5.9.2/src/repository.js new file mode 100644 index 0000000..c7ba23b --- /dev/null +++ b/capitulo_5/5.9.2/src/repository.js @@ -0,0 +1,74 @@ +const { ScanCommand, GetCommand, PutCommand, DeleteCommand } = require('@aws-sdk/lib-dynamodb') +const debug = require('debug')('wbruno:repository') +const { nanoid } = require('nanoid') +const { documentClient } = require('./config/dynamodb') +const getNextLink = (data) => { + if (data?.LastEvaluatedKey) { + const urlParams = qs.stringify(data.LastEvaluatedKey) + return { + rel: 'next', + href: `?${urlParams}` + } + } + return {} +} +const repository = { + DEFAULT_SIZE: '50', + TABLE_NAME: 'Shortener', + + list(query, sizeParam) { + const size = parseInt(sizeParam || this.DEFAULT_SIZE, 10) + + const config = { + TableName: this.TABLE_NAME, + Limit: size + } + + if (Reflect.has(query, 'id')) { + config.ExclusiveStartKey = { id: query.id } + } + debug({ config }) + + const params = new ScanCommand(config) + return documentClient.send(params).then((result) => { + return { + size, + links: [getNextLink(result)], + items: result.Items || [] + } + }) + }, + + byId(id) { + debug({ id }) + const params = new GetCommand({ + Key: { id }, + TableName: this.TABLE_NAME + }) + return documentClient.send(params).then((result) => result.Item) + }, + + put(data, existendId) { + const id = existendId || nanoid() + const params = new PutCommand({ + TableName: this.TABLE_NAME, + Item: { + id, + ...data + }, + ReturnValues: 'ALL_OLD' + }) + + return documentClient.send(params) + }, + + delete(id) { + const params = new DeleteCommand({ + TableName: this.TABLE_NAME, + Key: { id } + }) + + return documentClient.send(params).then((_) => ({})) + } +} +module.exports = repository \ No newline at end of file diff --git a/capitulo_5/5.9.2/src/shortener.js b/capitulo_5/5.9.2/src/shortener.js new file mode 100644 index 0000000..b408d69 --- /dev/null +++ b/capitulo_5/5.9.2/src/shortener.js @@ -0,0 +1,71 @@ +const debug = require('debug')('wbruno:shortener') +const repository = require('./repository') + +const headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Credentials': true +} + +const list = (event) => { + const query = event.queryStringParameters || {} + + return repository.list(query, query.size).then((result) => { + return { statusCode: 200, headers, body: JSON.stringify(result) } + }) +} + +const byId = (event) => { + const params = event.pathParameters + + return repository + .byId(params.id) + .then((result) => { + if (!result?.link) { + throw new Error('not found') + } + return result + }) + .then((result) => { + return { statusCode: 200, headers, body: JSON.stringify(result) } + }) + .catch((err) => { + return { statusCode: 404, body: JSON.stringify(err) } + }) +} + +const create = (event) => { + const body = JSON.parse(event.body || '{}') + + debug('create', body) + + return repository.put(body).then((_) => { + const result = { message: 'created' } + return { statusCode: 201, headers, body: JSON.stringify(result) } + }) +} + +const update = (event) => { + const params = event.pathParameters + const body = JSON.parse(event.body || '{}') + + debug('update', body) + + return repository.put(body, params.id).then((_) => { + const result = { message: 'updated' } + return { statusCode: 200, headers, body: JSON.stringify(result) } + }) +} + +const remove = (event) => { + const params = event.pathParameters + + debug('delete', params.id) + + return repository.delete(params.id).then(() => { + return { statusCode: 200, headers, body: '' } + }) +} + +module.exports = { + list, byId, create, update, remove +} \ No newline at end of file diff --git a/capitulo_5/5.9.2/test/handler.test.js b/capitulo_5/5.9.2/test/handler.test.js new file mode 100644 index 0000000..af5938e --- /dev/null +++ b/capitulo_5/5.9.2/test/handler.test.js @@ -0,0 +1,23 @@ +const { redirect } = require('../src/handler') +const event = { + "resource": "/{id}", + "path": "/iNxun1bg5Y", + "httpMethod": "GET", + "pathParameters": { + "id": "iNxun1bg5Y" + }, + "body": null, + "isBase64Encoded": false +} + +it('#redirect', async () => { + const result = await redirect(event) + expect(result.statusCode).toBe(301) + expect(result.headers).toEqual({ Location: 'https://www.novatec.com.br/livros/nodejs-3ed/' }) + expect(typeof result.body).toBe('string') +}) +it('#redirect link non existent', async () => { + const result = await redirect({ pathParameters: { id: 'non-existent' }}) + expect(result.statusCode).toBe(404) + expect(result.body).toBe('{}') +}) \ No newline at end of file diff --git a/capitulo_5/5.9.2/test/setup.js b/capitulo_5/5.9.2/test/setup.js new file mode 100644 index 0000000..54a162a --- /dev/null +++ b/capitulo_5/5.9.2/test/setup.js @@ -0,0 +1,24 @@ +require('dotenv').config() +const { mockClient } = require('aws-sdk-client-mock') +const { DynamoDBDocumentClient, GetCommand, QueryCommand, PutCommand, ScanCommand, DeleteCommand } = require ('@aws-sdk/lib-dynamodb') +const ddbMock = mockClient(DynamoDBDocumentClient) +const mockData = { + link: 'https://www.novatec.com.br/livros/nodejs-3ed/' +} +ddbMock + .on(GetCommand) + .resolves({ Item: undefined }) + .on(GetCommand, { + TableName: 'Shortener', + Key: { id: 'iNxun1bg5Y' } + }) + .resolves({ Item: mockData }) + +ddbMock + .on(QueryCommand) + .resolves({ Items: [mockData] }) + +ddbMock + .on(PutCommand).resolves({}) + .on(DeleteCommand).resolves({}) +ddbMock.on(ScanCommand).resolves({ Items: [mockData] }) \ No newline at end of file diff --git a/capitulo_5/5.9.2/test/shortener.test.js b/capitulo_5/5.9.2/test/shortener.test.js new file mode 100644 index 0000000..93f7078 --- /dev/null +++ b/capitulo_5/5.9.2/test/shortener.test.js @@ -0,0 +1,69 @@ +const { byId, create, remove, list, update } = require('../src/shortener') + +it('#byId', async () => { + const result = await byId({ + headers: {}, + pathParameters: { id: 'iNxun1bg5Y' } + }) + const expected = { + link: 'https://www.novatec.com.br/livros/nodejs-3ed/' + } + const body = JSON.parse(result.body || '{}') + + expect(expected).toEqual(body) + expect(result.statusCode).toEqual(200) + expect(typeof result.body).toEqual('string') +}) + +it('#create', async () => { + const result = await create({ + body: '{"link": "https://www.novatec.com.br/livros/nodejs-3ed/"}', + pathParameters: {} + }) + const expected = { + message: 'created' + } + const body = JSON.parse(result.body || '{}') + + expect(expected).toEqual(body) + expect(201).toEqual(result.statusCode) + expect(typeof result.body).toEqual('string') +}) +it('#list', async () => { + const result = await list({ headers: {} }) + const expected = { + size: 50, + links: [{}], + items: [{ link: 'https://www.novatec.com.br/livros/nodejs-3ed/' }] + } + const body = JSON.parse(result.body || '{}') + + expect(body).toEqual(expected) + expect(result.statusCode).toEqual(200) + expect(typeof result.body).toEqual('string') +}) +it('#remove', async () => { + const result = await remove({ + headers: {}, + pathParameters: { id: 'iNxun1bg5Y' } + }) + + expect(result.body).toEqual('') + expect(result.statusCode).toEqual(200) + expect(typeof result.body).toEqual('string') +}) + +it('#update', async () => { + const result = await update({ + body: '{"link": "https://www.novatec.com.br/livros/nodejs-3ed/"}', + pathParameters: { id: 'iNxun1bg5Y' } + }) + const expected = { + message: 'updated' + } + const body = JSON.parse(result.body || '{}') + + expect(expected).toEqual(body) + expect(200).toEqual(result.statusCode) + expect(typeof result.body).toEqual('string') +}) \ No newline at end of file