├── .dockerignore ├── .gitignore ├── LICENSE ├── POPULATE.sh ├── README.md ├── TUTORIAL.md ├── apps ├── discounts │ ├── .eslintrc.js │ ├── Dockerfile │ ├── package.json │ ├── src │ │ ├── app.ts │ │ ├── config.ts │ │ ├── controllers │ │ │ ├── __tests__ │ │ │ │ ├── findDiscount.spec.ts │ │ │ │ └── findDiscounts.spec.ts │ │ │ ├── findDiscount.ts │ │ │ ├── findDiscounts.ts │ │ │ └── interfaces.ts │ │ ├── dependencies.ts │ │ ├── index.ts │ │ ├── models │ │ │ ├── discount.ts │ │ │ ├── product.ts │ │ │ └── user.ts │ │ └── services │ │ │ ├── __tests__ │ │ │ ├── findDiscounts.spec.ts │ │ │ ├── makeDiscount.spec.ts │ │ │ └── rules.spec.ts │ │ │ ├── findDiscounts.ts │ │ │ ├── interfaces.ts │ │ │ ├── makeDiscount.ts │ │ │ └── rules.ts │ └── tsconfig.json ├── products │ ├── Dockerfile │ ├── config │ │ └── config.go │ ├── http │ │ └── server.go │ ├── main.go │ ├── migrations │ │ ├── 1_Bootstrap.go │ │ └── run.go │ ├── models │ │ ├── discount.go │ │ └── product.go │ ├── rpc │ │ └── server.go │ └── services │ │ └── product.go └── users │ ├── .eslintrc.js │ ├── Dockerfile │ ├── package.json │ ├── src │ ├── app.ts │ ├── config │ │ ├── index.ts │ │ └── ormconfig.ts │ ├── controllers │ │ ├── createUser.ts │ │ ├── interfaces.ts │ │ ├── listUsers.ts │ │ └── readUser.ts │ ├── dependencies.ts │ ├── index.ts │ ├── migrations │ │ └── 1568307883445-Bootstrap.ts │ └── models │ │ └── User.ts │ ├── tests │ └── api.spec.ts │ ├── tsconfig.json │ └── typeorm.sh ├── docker-compose.yml ├── go.mod ├── go.sum ├── hash-test.code-workspace ├── package.json ├── packages ├── protos │ ├── BUILD.sh │ ├── README.md │ ├── bin │ │ └── protoc-gen-go │ ├── package.json │ └── src │ │ ├── discounts.proto │ │ ├── google │ │ ├── any.proto │ │ ├── code.proto │ │ └── status.proto │ │ ├── products.proto │ │ └── users.proto └── utils │ ├── .eslintrc.js │ ├── package.json │ ├── src │ ├── __tests__ │ │ ├── cache.spec.ts │ │ ├── errorHandler.spec.ts │ │ ├── logger.spec.ts │ │ ├── memoize.spec.ts │ │ ├── respond.spec.ts │ │ └── validation.spec.ts │ ├── cache.ts │ ├── grpc │ │ ├── errorHandler.ts │ │ ├── index.ts │ │ ├── interfaces.ts │ │ ├── logger.ts │ │ ├── memoize.ts │ │ ├── respond.ts │ │ └── validation.ts │ └── index.ts │ └── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | # .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | dist 76 | 77 | _build 78 | 79 | deps 80 | 81 | package-lock.json 82 | 83 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | # .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | dist 76 | 77 | _build 78 | 79 | deps 80 | 81 | package-lock.json 82 | 83 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 EduardoRFS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /POPULATE.sh: -------------------------------------------------------------------------------- 1 | echo ' 2 | cd /code 3 | yarn 4 | 5 | cd /code/packages/protos 6 | yarn build 7 | 8 | cd /code/packages/utils 9 | yarn build 10 | 11 | cd /code/apps/users 12 | yarn migration:run 13 | ' | docker run --rm -i --network=hash-test_default -v $PWD:/code node:12 /bin/bash 14 | 15 | echo ' 16 | cd /code/apps/products 17 | go run migrations/*.go init 18 | go run migrations/*.go up 19 | ' | docker run --rm -i --network=hash-test_default -v $PWD:/code golang:1.13-alpine /bin/ash 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hash Teste Back-end 2 | 3 | ## Para instruções sobre como usar e testar leia o `TUTORIAL.md` 4 | 5 | ## Contextualização 6 | 7 | Cada parte do teste tem como objetivo pessoal atingir escalabilidade, e fácil substituição, as aplicações foram escritas primariamente seguindo dois paradigmas. 8 | 9 | Um foi utilizado pelas aplicações em TypeScript, foram desenvolvidos buscando atingir o isolamento e redução de lógica necessária e aumentar a testabilidade. Essencialmente se compila, a aplicação funciona e se os testes passam, ela funciona como desejado. 10 | 11 | O outro paradigma, é mais "XGH", foi desenvolvido com o objetivo de ser essencialmente um MVP, utilizando uma tecnologia a qual é muito fora do que estou acostumado(Go) e sobre um budget de tempo. Por ambos os motivos existem alguns problemas internos da aplicação e a ausência de testes, mas dado que a API externa foi bem desenhada a possibilidade de substituição e escalabilidade se manteve. 12 | 13 | Mesmo que a proposta do teste seja apenas dois serviços, para simplificar a arquitetura e implementação, foi adicionado um terceiro para controle e persistência de usuários. 14 | 15 | ## Segurança 16 | 17 | Os serviços estão rodando sem SSL, pois existe uma presunção que eles serão rodados em uma máquina única e ao menos em uma VPN(ou Amazon VPC), mesmo que o segundo não seja o ideal, a vantagem disto é que é muito mais simples configurar o ambiente, existe um ganho de performance e não inclui a complexidade inerente ao armazenamento de chaves privadas. 18 | 19 | ## Terminologia 20 | 21 | | Nome | Função | 22 | | ---------- | --------------------------- | 23 | | controller | Controle de entrada e saída | 24 | | service | Lógica de negócio | 25 | | model | Camada de persistência | 26 | 27 | ## Fluxo de dados 28 | 29 | Existem dois possíveis fluxos para chamadas que são: 30 | 31 | - `controller <-> model`. 32 | - `controller <-> service <-> model` 33 | 34 | O primeiro é essencialmente para persistência de dados, aonde a presença de lógica de negócio é irrisória ou nula. 35 | 36 | O segundo é para chamadas que possuam lógica de negócio, podendo caso o service chamar a model diversas vezes, mas a controller deve chamar apenas um service. 37 | 38 | ## /apps/discounts 39 | 40 | Responsável pelo cálculo de descontos, fazendo integração com o serviço de usuários e produtos. 41 | 42 | Assim como o serviço de usuários foi feito uma inversão de controle, conceitualmente `dependency injection`. Para não gerar complexidade e repetição de código desnecessária as interfaces utilizadas são controlados pelo arquivo responsável pela implementação, mesmo que isso gere um pouco mais de acoplamento, a vantagem ganhada é considerável e ainda permite o maior diferencial que é facilidade de testes unitários se necessário. 43 | 44 | Esse serviço foi testado utilizando-se apenas de testes unitários, dado a complexidade desnecessária que seria subir os outros serviços e suas dependências, toda a camada de negócios e controle foram testadas e pela ausência de lógica na camada de persistência tais testes convém confiabilidade suficiente. 45 | 46 | ## /apps/users 47 | 48 | Esse service é responsável pela persistência de dados de usuários, o motivo da existência é por que de um ponto de vista de escalabilidade é muito mais simples se cada serviço fosse responsável pelo seu próprio banco de dados. 49 | 50 | Como esse serviço depende apenas de um banco de dados, é muito simples de escrever testes de integração e fácil de executar, apenas sendo necessário um alias no /etc/hosts e um banco rodando, ou um docker-compose.yml em caso de um CI. 51 | 52 | ## /apps/products 53 | 54 | Esse é o serviço externo, responsável por servir a rota `/product` na porta `8080`, fazer a integração com o serviço de disconto e servir como persistência de dados de produtos. 55 | 56 | Ele é diferente, com 2 conjuntos de controllers uma para servir HTTP e outro para servir GRPC, foi desenvolvido sem muito estudo de convenções e desenho de código idiomático para a plataforma, se atendo estritamente a ser de rápido desenvolvimento, porém criando objetivamente um débito técnico. 57 | 58 | ## /packages/protos 59 | 60 | Pacote responsável pelo build dos protobuffers para uso compartilhado por aplicações a vantagem primária de estar em um monorepo não versionado é que em caso de uma breaking change acidental o CI irá falhar no build de qualquer uma das aplicações, assim reduzindo bastante a chance deste e outros tipos de erros. 61 | 62 | ## /packages/utils 63 | 64 | Dado o fato do gRPC não ser desenvolvido de uma forma idiomática para JavaScript em especial JavaScript moderno, temos alguns problemas de boilerplate que acabam aumentando consideravelmente a superfície da aplicação, ao fazer alguns utilitários que podem ser facilmente testados reduz-se esse boilerplate largamente e se define algumas convenções como é o caso do campo de `status` nos protobuffers, e dado as restrições aplicadas pelo TypeScript, uma resposta sem esse campo não permitirá a aplicação nem a compilar. 65 | 66 | ## Considerações adicionais 67 | 68 | Foi utilizado alguns forks pessoais por limitações de tempo e burocracia para passar algumas PR, o `grpc-node` stock não possui suporte a promises e por tal motivo não é possível extrair facilmente do `grpc_tools_node_protoc_ts` os tipos de Request e Response. Com a alteração em ambos, em conjunto com a definition alterada do `mali.js` é possível em tempo de build se validar se está usando a Request e Response correta, além de melhorar bastante a experiência de desenvolvimento(autocomplete). 69 | 70 | Originalmente eu estava pensando em utilizar streams, mas dado a não ser recomendado e possuir certa complexidade adicional, foi abandonado a escolha, porém a type definition ainda consta no projeto. 71 | 72 | - https://github.com/EduardoRFS/grpc-native-core.git#20ba246427f179d5eaaf99bcca893fd7ccc29689 73 | - https://github.com/EduardoRFS/mali.git#b7de8855b5413b65f41a4943f6b4c9f2c12fd425 74 | -------------------------------------------------------------------------------- /TUTORIAL.md: -------------------------------------------------------------------------------- 1 | ## Requerimentos 2 | 3 | - docker e docker-compose instalado 4 | 5 | ## Deploy 6 | 7 | Sobe, aplicações e bancos de dados, definidos no docker-compose.yml 8 | 9 | ```sh 10 | docker-compose up --build -d # / 11 | ``` 12 | 13 | ## Populate 14 | 15 | Para rodar migrations no banco de dados e popular os dados 16 | 17 | ```sh 18 | ./POPULATE.sh # / 19 | ``` 20 | 21 | ## Testar 22 | 23 | Teste ambos os comandos um irá estar de aniversário hoje e logo terá um desconto 24 | 25 | ```sh 26 | curl -H "X-USER-ID: b9ca41e9-9ce9-4852-8b11-c6386cfb0e25" localhost:8080/product 27 | curl -H "X-USER-ID: 3f045e1b-3ff7-429c-9ca1-e4e7585b48a6" localhost:8080/product 28 | ``` 29 | 30 | ## Acesso aos bancos internos 31 | 32 | Retorna IPs para acesso ao banco dentro do docker 33 | 34 | ```sh 35 | yarn databases # / 36 | ``` 37 | -------------------------------------------------------------------------------- /apps/discounts/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { project: './tsconfig.json' }, 3 | extends: [ 4 | 'airbnb-typescript/base', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 7 | 'plugin:prettier/recommended', 8 | 'prettier/@typescript-eslint', 9 | ], 10 | rules: { 11 | '@typescript-eslint/no-floating-promises': 'error', 12 | 'class-methods-use-this': 0, 13 | '@typescript-eslint/explicit-function-return-type': 0, 14 | 'import/no-cycle': 0, 15 | 'import/prefer-default-export': 0, 16 | 'no-restricted-syntax': 0, 17 | }, 18 | overrides: [ 19 | { 20 | files: ['**/__tests__/**/*.spec.ts', '*.spec.ts'], 21 | extends: ['plugin:jest/recommended'], 22 | rules: { 23 | 'jest/valid-describe': 0, 24 | 'no-shadow': 0, 25 | }, 26 | }, 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /apps/discounts/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 as build 2 | 3 | RUN mkdir -p /build 4 | WORKDIR /build 5 | 6 | # cache deps 7 | COPY package.json yarn.lock ./ 8 | COPY packages/utils/package.json ./packages/utils/package.json 9 | COPY packages/protos/package.json ./packages/protos/package.json 10 | COPY apps/discounts/package.json ./apps/discounts/package.json 11 | RUN yarn --pure-lockfile 12 | 13 | # proto 14 | COPY packages/protos /build/packages/protos 15 | WORKDIR /build/packages/protos 16 | RUN yarn build 17 | 18 | # utils 19 | COPY packages/utils /build/packages/utils 20 | WORKDIR /build/packages/utils 21 | RUN yarn build 22 | 23 | # discounts 24 | COPY apps/discounts /build/apps/discounts 25 | WORKDIR /build/apps/discounts 26 | RUN yarn build 27 | 28 | # clean deps 29 | WORKDIR /build 30 | RUN NODE_ENV=production yarn --pure-lockfile 31 | 32 | FROM node:12-alpine 33 | 34 | RUN mkdir -p /deploy 35 | COPY --from=build /build /deploy 36 | 37 | WORKDIR /deploy/apps/discounts 38 | 39 | CMD ["yarn", "start"] 40 | -------------------------------------------------------------------------------- /apps/discounts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hash/discounts", 3 | "version": "0.0.0", 4 | "main": "dist/index.js", 5 | "author": "EduardoRFS ", 6 | "license": "MIT", 7 | "private": true, 8 | "engines": { 9 | "node": ">=10.9" 10 | }, 11 | "scripts": { 12 | "test": "jest --coverage", 13 | "build": "tsc", 14 | "start": "NODE_ENV=production node dist/src/index.js" 15 | }, 16 | "devDependencies": { 17 | "@types/date-fns": "^2.6.0", 18 | "@types/jest": "^24.0.16", 19 | "@types/node": "https://github.com/EduardoRFS/types-node.git#6c454721f766383c872f87c24252be7391f90261", 20 | "@types/ramda": "^0.26.18", 21 | "@types/uuid": "^3.4.5", 22 | "@typescript-eslint/eslint-plugin": "^2.2.0", 23 | "@typescript-eslint/parser": "^2.2.0", 24 | "eslint": "^6.1.0", 25 | "eslint-config-airbnb-base": "^13.2.0", 26 | "eslint-config-airbnb-typescript": "^4.0.1", 27 | "eslint-config-prettier": "^6.0.0", 28 | "eslint-plugin-import": "^2.18.2", 29 | "eslint-plugin-jest": "^22.14.1", 30 | "eslint-plugin-prettier": "^3.1.0", 31 | "jest": "^24.8.0", 32 | "jest-date-mock": "^1.0.7", 33 | "jest-with-context": "^1.1.1", 34 | "prettier": "^1.18.2", 35 | "ts-jest": "^24.0.2", 36 | "typescript": "^3.5.3", 37 | "uuid": "^3.3.2" 38 | }, 39 | "dependencies": { 40 | "@hash/protos": "0.0.0", 41 | "@hash/utils": "0.0.0", 42 | "@malijs/compose": "^1.3.2", 43 | "date-fns": "^2.0.1", 44 | "grpc": "https://github.com/EduardoRFS/grpc-native-core.git#20ba246427f179d5eaaf99bcca893fd7ccc29689", 45 | "mali": "https://github.com/EduardoRFS/mali.git#b7de8855b5413b65f41a4943f6b4c9f2c12fd425", 46 | "ramda": "^0.26.1", 47 | "reflect-metadata": "^0.1.13", 48 | "ts-essentials": "^3.0.1", 49 | "yup": "^0.27.0" 50 | }, 51 | "prettier": { 52 | "tabWidth": 2, 53 | "singleQuote": true, 54 | "trailingComma": "es5" 55 | }, 56 | "jest": { 57 | "roots": [ 58 | "/src", 59 | "/tests" 60 | ], 61 | "testRegex": "(\\.|/)(test|spec)\\.[jt]sx?$", 62 | "preset": "ts-jest/presets/js-with-ts" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /apps/discounts/src/app.ts: -------------------------------------------------------------------------------- 1 | import Mali from 'mali'; 2 | import * as services from '@hash/protos/dist/discounts_grpc_pb'; 3 | import { logger } from '@hash/utils'; 4 | import dependencies from './dependencies'; 5 | import findDiscount from './controllers/findDiscount'; 6 | import findDiscounts from './controllers/findDiscounts'; 7 | 8 | const app = new Mali(services); 9 | 10 | app.use(logger(console)); 11 | 12 | app.use('findDiscount', findDiscount(dependencies)); 13 | app.use('findDiscounts', findDiscounts(dependencies)); 14 | 15 | export default app; 16 | -------------------------------------------------------------------------------- /apps/discounts/src/config.ts: -------------------------------------------------------------------------------- 1 | import { parse as parseDate } from 'date-fns'; 2 | 3 | export interface Config { 4 | listen: string; 5 | cache: { 6 | maxAge: number; 7 | maxSize: number; // entries 8 | }; 9 | services: { 10 | users: string; 11 | products: string; 12 | }; 13 | discount: { 14 | birthday: { percentage: number }; 15 | blackfriday: { day: Date; percentage: number }; 16 | max: { percentage: number }; 17 | }; 18 | } 19 | 20 | const config: Config = { 21 | listen: '0.0.0.0:50051', 22 | cache: { 23 | maxAge: 1000 * 60 * 60, // 1h; 24 | maxSize: 10240, 25 | }, 26 | services: { 27 | users: 'api-users:50051', 28 | products: 'api-products:50051', 29 | }, 30 | discount: { 31 | birthday: { percentage: 5 }, 32 | blackfriday: { 33 | day: parseDate('25/11', 'dd/MM', new Date()), 34 | percentage: 10, 35 | }, 36 | max: { percentage: 10 }, 37 | }, 38 | }; 39 | export default config; 40 | -------------------------------------------------------------------------------- /apps/discounts/src/controllers/__tests__/findDiscount.spec.ts: -------------------------------------------------------------------------------- 1 | import Mali from 'mali'; 2 | import uuid from 'uuid/v4'; 3 | import { 4 | FindDiscountRequest, 5 | Discount, 6 | FindDiscountResponse, 7 | } from '@hash/protos/dist/discounts_pb'; 8 | import { memoize } from '@hash/utils'; 9 | import { Code } from '@hash/protos/dist/google/code_pb'; 10 | import { Status } from '@hash/protos/dist/google/status_pb'; 11 | import createFindDiscount from '../findDiscount'; 12 | 13 | const request = async ( 14 | findDiscount: any, 15 | productId: string, 16 | userId: string, 17 | code: Code, 18 | pct?: number, 19 | valueInCents?: number 20 | ) => { 21 | const request = new FindDiscountRequest(); 22 | if (productId) { 23 | request.setProductId(productId); 24 | } 25 | if (userId) { 26 | request.setUserId(userId); 27 | } 28 | const context = { req: request } as Mali.Context; 29 | await findDiscount(context); 30 | const response = context.res as FindDiscountResponse; 31 | const status = response.getStatus() as Status; 32 | const discount = response.getDiscount() as Discount; 33 | 34 | expect(status).toBeInstanceOf(Status); 35 | expect(status.getCode()).toBe(code); 36 | 37 | expect(discount).toBeInstanceOf(Discount); 38 | expect(discount.getPct()).toBe(pct); 39 | expect(discount.getValueInCents()).toBe(valueInCents); 40 | }; 41 | 42 | test('found items', async () => { 43 | const productId = uuid(); 44 | const userId = uuid(); 45 | 46 | const cache = memoize.cache({ maxSize: 2 }); 47 | const services = { 48 | findDiscounts: jest.fn(pairs => { 49 | expect(pairs).toEqual([{ productId, userId }]); 50 | const discount = new Discount(); 51 | discount.setPct(5); 52 | return Promise.resolve([discount]); 53 | }), 54 | }; 55 | const findDiscount = createFindDiscount({ cache, services }); 56 | 57 | await request(findDiscount, productId, userId, Code.OK, 5, 0); 58 | 59 | expect(services.findDiscounts).toHaveBeenCalled(); 60 | }); 61 | test('is using cache', async () => { 62 | const productId = uuid(); 63 | const userId = uuid(); 64 | 65 | const cache = memoize.cache({ maxSize: 2, maxAge: 10000 }); 66 | const services = { 67 | findDiscounts: jest.fn(() => { 68 | const discount = new Discount(); 69 | discount.setPct(3); 70 | discount.setValueInCents(15); 71 | return Promise.resolve([discount]); 72 | }), 73 | }; 74 | const findDiscount = createFindDiscount({ cache, services }); 75 | 76 | await request(findDiscount, productId, userId, Code.OK, 3, 15); 77 | await request(findDiscount, productId, userId, Code.OK, 3, 15); 78 | 79 | expect(services.findDiscounts).toHaveBeenCalledTimes(1); 80 | }); 81 | -------------------------------------------------------------------------------- /apps/discounts/src/controllers/__tests__/findDiscounts.spec.ts: -------------------------------------------------------------------------------- 1 | import Mali from 'mali'; 2 | import uuid from 'uuid/v4'; 3 | import R from 'ramda'; 4 | import { 5 | Discount, 6 | DiscountRequest, 7 | FindDiscountsRequest, 8 | FindDiscountsResponse, 9 | } from '@hash/protos/dist/discounts_pb'; 10 | import { memoize } from '@hash/utils'; 11 | import { Code } from '@hash/protos/dist/google/code_pb'; 12 | import { Status } from '@hash/protos/dist/google/status_pb'; 13 | import createFindDiscounts from '../findDiscounts'; 14 | 15 | const request = async ( 16 | findDiscounts: any, 17 | pairs: { productId: string; userId: string }[], 18 | code: Code, 19 | results: { pct: number; valueInCents: number }[] 20 | ) => { 21 | const request = new FindDiscountsRequest(); 22 | const requestList = pairs.map(({ productId, userId }) => { 23 | const request = new DiscountRequest(); 24 | request.setProductId(productId); 25 | request.setUserId(userId); 26 | return request; 27 | }); 28 | request.setRequestsList(requestList); 29 | 30 | const context = { req: request } as Mali.Context; 31 | await findDiscounts(context); 32 | const response = context.res as FindDiscountsResponse; 33 | const status = response.getStatus() as Status; 34 | const discounts = response.getDiscountList(); 35 | 36 | expect(status).toBeInstanceOf(Status); 37 | expect(status.getCode()).toBe(code); 38 | 39 | expect(discounts.length).toBe(pairs.length); 40 | expect(discounts.length).toBe(results.length); 41 | 42 | R.zip(results, discounts).forEach(([{ pct, valueInCents }, discount]) => { 43 | expect(discount.getPct()).toBe(pct); 44 | expect(discount.getValueInCents()).toBe(valueInCents); 45 | }); 46 | }; 47 | 48 | test('found items', async () => { 49 | const pairs = [ 50 | { productId: uuid(), userId: uuid() }, 51 | { productId: uuid(), userId: uuid() }, 52 | ]; 53 | const results = [{ pct: 5, valueInCents: 16 }, { pct: 0, valueInCents: 0 }]; 54 | 55 | const cache = memoize.cache({ maxSize: 2 }); 56 | const services = { 57 | findDiscounts: jest.fn(requestedPairs => { 58 | expect(pairs).toEqual(requestedPairs); 59 | const discount = new Discount(); 60 | discount.setPct(5); 61 | discount.setValueInCents(16); 62 | return Promise.resolve([discount, new Discount()]); 63 | }), 64 | }; 65 | const findDiscount = createFindDiscounts({ cache, services }); 66 | 67 | await request(findDiscount, pairs, Code.OK, results); 68 | 69 | expect(services.findDiscounts).toHaveBeenCalled(); 70 | }); 71 | test('is using cache', async () => { 72 | const pairs = [{ productId: uuid(), userId: uuid() }]; 73 | const results = [{ pct: 3, valueInCents: 15 }]; 74 | 75 | const cache = memoize.cache({ maxSize: 2, maxAge: 10000 }); 76 | const services = { 77 | findDiscounts: jest.fn(() => { 78 | const discount = new Discount(); 79 | discount.setPct(3); 80 | discount.setValueInCents(15); 81 | return Promise.resolve([discount]); 82 | }), 83 | }; 84 | const findDiscount = createFindDiscounts({ cache, services }); 85 | 86 | await request(findDiscount, pairs, Code.OK, results); 87 | await request(findDiscount, pairs, Code.OK, results); 88 | 89 | expect(services.findDiscounts).toHaveBeenCalledTimes(1); 90 | }); 91 | -------------------------------------------------------------------------------- /apps/discounts/src/controllers/findDiscount.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import compose from '@malijs/compose'; 3 | import { 4 | createRespond, 5 | createValidation, 6 | memoize, 7 | errorHandler, 8 | } from '@hash/utils'; 9 | import { FindDiscountResponse } from '@hash/protos/dist/discounts_pb'; 10 | import { FindDiscount } from './interfaces'; 11 | import { FindDiscounts } from '../services/findDiscounts'; 12 | 13 | interface DI { 14 | cache: memoize.Cache; 15 | services: { 16 | findDiscounts: FindDiscounts; 17 | }; 18 | } 19 | export default ({ cache, services }: DI) => { 20 | const errors = errorHandler(FindDiscountResponse); 21 | const validate = createValidation( 22 | FindDiscountResponse, 23 | yup.object({ 24 | productId: yup.string().required(), 25 | userId: yup.string(), 26 | }) 27 | ); 28 | 29 | const { ok } = createRespond(FindDiscountResponse); 30 | const findDiscount: FindDiscount = async ctx => { 31 | const productId = ctx.req.getProductId(); 32 | const userId = ctx.req.getUserId(); 33 | 34 | const [discount] = await services.findDiscounts([{ productId, userId }]); 35 | ctx.res = ok(res => res.setDiscount(discount)); 36 | }; 37 | return memoize(compose([errors, validate, findDiscount]), cache); 38 | }; 39 | -------------------------------------------------------------------------------- /apps/discounts/src/controllers/findDiscounts.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import compose from '@malijs/compose'; 3 | import { 4 | createRespond, 5 | createValidation, 6 | memoize, 7 | errorHandler, 8 | } from '@hash/utils'; 9 | import { FindDiscountsResponse } from '@hash/protos/dist/discounts_pb'; 10 | import { FindDiscounts } from './interfaces'; 11 | import { FindDiscounts as FindDiscountsService } from '../services/findDiscounts'; 12 | 13 | interface DI { 14 | cache: memoize.Cache; 15 | services: { 16 | findDiscounts: FindDiscountsService; 17 | }; 18 | } 19 | 20 | export default ({ cache, services }: DI) => { 21 | const errors = errorHandler(FindDiscountsResponse); 22 | const validate = createValidation( 23 | FindDiscountsResponse, 24 | yup.object({ 25 | requests: yup.array().of( 26 | yup 27 | .object({ 28 | productId: yup.string().required(), 29 | userId: yup.string(), 30 | }) 31 | .required() 32 | ), 33 | }) 34 | ); 35 | 36 | const { ok } = createRespond(FindDiscountsResponse); 37 | const findDiscounts: FindDiscounts = async ctx => { 38 | const requests = ctx.req 39 | .getRequestsList() 40 | .map(request => request.toObject()); 41 | 42 | const discounts = await services.findDiscounts(requests); 43 | ctx.res = ok(res => res.setDiscountList(discounts)); 44 | }; 45 | return memoize(compose([errors, validate, findDiscounts]), cache); 46 | }; 47 | -------------------------------------------------------------------------------- /apps/discounts/src/controllers/interfaces.ts: -------------------------------------------------------------------------------- 1 | import Mali from 'mali'; 2 | import * as services from '@hash/protos/dist/discounts_grpc_pb'; 3 | 4 | type Methods = Mali.Methods; 5 | export type FindDiscount = Methods['findDiscount']; 6 | export type FindDiscounts = Methods['findDiscounts']; 7 | -------------------------------------------------------------------------------- /apps/discounts/src/dependencies.ts: -------------------------------------------------------------------------------- 1 | import { memoize } from '@hash/utils'; 2 | import config from './config'; 3 | import createRules from './services/rules'; 4 | import createMakeDiscount from './services/makeDiscount'; 5 | import createFindDiscounts from './services/findDiscounts'; 6 | import * as products from './models/product'; 7 | import * as users from './models/user'; 8 | 9 | const models = { products, users }; 10 | 11 | const rules = createRules({ config }); 12 | const makeDiscount = createMakeDiscount({ services: { rules } }); 13 | const findDiscounts = createFindDiscounts({ 14 | services: { makeDiscount }, 15 | models, 16 | }); 17 | const services = { rules, makeDiscount, findDiscounts }; 18 | const dependencies = { 19 | config, 20 | services, 21 | models, 22 | cache: memoize.cache(config.cache), 23 | }; 24 | export default dependencies; 25 | -------------------------------------------------------------------------------- /apps/discounts/src/index.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import env from './config'; 3 | 4 | export default Promise.resolve().then(() => { 5 | const server = app.start(env.listen); 6 | 7 | // eslint-disable-next-line no-console 8 | console.log(`Running at ${env.listen}`); 9 | return { server }; 10 | }); 11 | -------------------------------------------------------------------------------- /apps/discounts/src/models/discount.ts: -------------------------------------------------------------------------------- 1 | import grpc from 'grpc'; 2 | import { DiscountsServiceClient } from '@hash/protos/dist/discounts_grpc_pb'; 3 | import config from '../config'; 4 | 5 | export default new DiscountsServiceClient( 6 | 'localhost:50051', 7 | grpc.credentials.createInsecure() 8 | ); 9 | -------------------------------------------------------------------------------- /apps/discounts/src/models/product.ts: -------------------------------------------------------------------------------- 1 | import R from 'ramda'; 2 | import grpc from 'grpc'; 3 | import { ProductsServiceClient } from '@hash/protos/dist/products_grpc_pb'; 4 | import { 5 | ReadProductRequest, 6 | ListProductsRequest, 7 | } from '@hash/protos/dist/products_pb'; 8 | import config from '../config'; 9 | 10 | const service = new ProductsServiceClient( 11 | config.services.products, 12 | grpc.credentials.createInsecure() 13 | ); 14 | export const findById = async (id: string) => { 15 | const request = new ReadProductRequest(); 16 | request.setId(id); 17 | 18 | const response = await service.readProduct(request); 19 | return response.getProduct(); 20 | }; 21 | export const findByIds = async (ids: string[]) => { 22 | const request = new ListProductsRequest(); 23 | request.setIdList(ids); 24 | 25 | const response = await service.listProducts(request); 26 | const products = response.getProductsList(); 27 | return R.indexBy(product => product.getId(), products); 28 | }; 29 | 30 | export type FindById = typeof findById; 31 | export type FindByIds = typeof findByIds; 32 | -------------------------------------------------------------------------------- /apps/discounts/src/models/user.ts: -------------------------------------------------------------------------------- 1 | import R from 'ramda'; 2 | import grpc from 'grpc'; 3 | import { UsersServiceClient } from '@hash/protos/dist/users_grpc_pb'; 4 | import { ReadUserRequest, ListUsersRequest } from '@hash/protos/dist/users_pb'; 5 | import config from '../config'; 6 | 7 | const service = new UsersServiceClient( 8 | config.services.users, 9 | grpc.credentials.createInsecure() 10 | ); 11 | export const findById = async (id: string) => { 12 | const request = new ReadUserRequest(); 13 | request.setId(id); 14 | 15 | const response = await service.readUser(request); 16 | return response.getUser(); 17 | }; 18 | export const findByIds = async (ids: string[]) => { 19 | const request = new ListUsersRequest(); 20 | request.setIdList(ids); 21 | 22 | const response = await service.listUsers(request); 23 | const users = response.getUsersList(); 24 | return R.indexBy(user => user.getId(), users); 25 | }; 26 | 27 | export type FindById = typeof findById; 28 | export type FindByIds = typeof findByIds; 29 | -------------------------------------------------------------------------------- /apps/discounts/src/services/__tests__/findDiscounts.spec.ts: -------------------------------------------------------------------------------- 1 | import R from 'ramda'; 2 | import uuid from 'uuid/v4'; 3 | import { User } from '@hash/protos/dist/users_pb'; 4 | import { Product } from '@hash/protos/dist/products_pb'; 5 | import { Discount } from '@hash/protos/dist/discounts_pb'; 6 | import createFindDiscounts from '../findDiscounts'; 7 | 8 | const assert = async ( 9 | baseProducts: { id: string; priceInCents: number }[], 10 | baseUsers: { id: string }[], 11 | pairs: { productId: string; userId: string }[], 12 | results: { pct: number; valueInCents: number }[] 13 | ) => { 14 | const products = baseProducts.map(({ id, priceInCents }) => { 15 | const product = new Product(); 16 | product.setId(id); 17 | product.setPriceInCents(priceInCents); 18 | return product; 19 | }); 20 | const users = baseUsers.map(({ id }) => { 21 | const user = new User(); 22 | user.setId(id); 23 | return user; 24 | }); 25 | const findDiscounts = createFindDiscounts({ 26 | services: { 27 | makeDiscount(product, user) { 28 | const discount = new Discount(); 29 | const percentage = user ? 5 : 3; 30 | discount.setPct(percentage); 31 | discount.setValueInCents( 32 | product.getPriceInCents() * (percentage / 100) 33 | ); 34 | return discount; 35 | }, 36 | }, 37 | models: { 38 | products: { 39 | findByIds(ids) { 40 | expect(ids).toEqual(pairs.map(pair => pair.productId)); 41 | const items = products.filter(product => 42 | ids.includes(product.getId()) 43 | ); 44 | const productsById = R.indexBy(product => product.getId(), items); 45 | return Promise.resolve(productsById); 46 | }, 47 | }, 48 | users: { 49 | findByIds(ids) { 50 | expect(ids).toEqual(pairs.map(pair => pair.userId)); 51 | const items = users.filter(user => ids.includes(user.getId())); 52 | const usersById = R.indexBy(user => user.getId(), items); 53 | return Promise.resolve(usersById); 54 | }, 55 | }, 56 | }, 57 | }); 58 | 59 | const discounts = await findDiscounts(pairs); 60 | expect(discounts.length).toBe(pairs.length); 61 | expect(discounts.length).toBe(results.length); 62 | 63 | R.zip(results, discounts).forEach(([result, discount]) => { 64 | expect(discount.getPct()).toBe(result.pct); 65 | expect(discount.getValueInCents()).toBe(result.valueInCents); 66 | }); 67 | }; 68 | test('single discount', async () => { 69 | const product = { id: uuid(), priceInCents: 200 }; 70 | const user = { id: uuid() }; 71 | await assert( 72 | [product], 73 | [user], 74 | [{ productId: product.id, userId: user.id }], 75 | [{ pct: 5, valueInCents: 10 }] 76 | ); 77 | }); 78 | test('multiple product, single user', async () => { 79 | const products = [ 80 | { id: uuid(), priceInCents: 300 }, 81 | { id: uuid(), priceInCents: 400 }, 82 | { id: uuid(), priceInCents: 500 }, 83 | { id: uuid(), priceInCents: 600 }, 84 | { id: uuid(), priceInCents: 700 }, 85 | ]; 86 | const user = { id: uuid() }; 87 | await assert( 88 | products, 89 | [user], 90 | [ 91 | { productId: products[1].id, userId: user.id }, 92 | { productId: products[2].id, userId: user.id }, 93 | { productId: products[3].id, userId: user.id }, 94 | ], 95 | [ 96 | { pct: 5, valueInCents: 20 }, 97 | { pct: 5, valueInCents: 25 }, 98 | { pct: 5, valueInCents: 30 }, 99 | ] 100 | ); 101 | }); 102 | test('missing user', async () => { 103 | const products = [ 104 | { id: uuid(), priceInCents: 300 }, 105 | { id: uuid(), priceInCents: 400 }, 106 | { id: uuid(), priceInCents: 500 }, 107 | ]; 108 | const user = { id: uuid() }; 109 | await assert( 110 | products, 111 | [user], 112 | [ 113 | { productId: products[0].id, userId: user.id }, 114 | // this user doesn't exists 115 | { productId: products[1].id, userId: uuid() }, 116 | { productId: products[2].id, userId: user.id }, 117 | ], 118 | [ 119 | { pct: 5, valueInCents: 15 }, 120 | { pct: 3, valueInCents: 12 }, 121 | { pct: 5, valueInCents: 25 }, 122 | ] 123 | ); 124 | }); 125 | test('missing product', async () => { 126 | const products = [ 127 | { id: uuid(), priceInCents: 300 }, 128 | { id: uuid(), priceInCents: 400 }, 129 | { id: uuid(), priceInCents: 500 }, 130 | ]; 131 | const user = { id: uuid() }; 132 | await assert( 133 | products.slice(1), 134 | [user], 135 | [ 136 | // this product isn't available 137 | { productId: products[0].id, userId: user.id }, 138 | { productId: products[1].id, userId: user.id }, 139 | { productId: products[2].id, userId: user.id }, 140 | ], 141 | [ 142 | { pct: 0, valueInCents: 0 }, 143 | { pct: 5, valueInCents: 20 }, 144 | { pct: 5, valueInCents: 25 }, 145 | ] 146 | ); 147 | }); 148 | -------------------------------------------------------------------------------- /apps/discounts/src/services/__tests__/makeDiscount.spec.ts: -------------------------------------------------------------------------------- 1 | import { Discount } from '@hash/protos/dist/discounts_pb'; 2 | import { Product } from '@hash/protos/dist/products_pb'; 3 | import { User } from '@hash/protos/dist/users_pb'; 4 | import createMakeDiscount from '../makeDiscount'; 5 | import { Rules, Rule } from '../rules'; 6 | 7 | const assert = ( 8 | rules: Rules, 9 | priceInCents: number, 10 | dateOfBirth: number, 11 | pct: number, 12 | valueInCents: number 13 | ) => { 14 | const makeDiscount = createMakeDiscount({ services: { rules } }); 15 | const product = new Product(); 16 | product.setPriceInCents(priceInCents); 17 | 18 | const user = new User(); 19 | user.setDateOfBirth(dateOfBirth); 20 | 21 | const discount = makeDiscount(product, user); 22 | 23 | expect(discount.getPct()).toBe(pct); 24 | expect(discount.getValueInCents()).toBe(valueInCents); 25 | }; 26 | // minus ten cents if is divisible by 100, ex: 6 became 5.90 27 | const baseRule: Rule = ({ product }) => { 28 | const priceInCents = product.getPriceInCents(); 29 | if (priceInCents % 100 === 0) { 30 | const discount = new Discount(); 31 | discount.setPct(0); 32 | discount.setValueInCents(10); 33 | return discount; 34 | } 35 | return null; 36 | }; 37 | test('no rule', () => { 38 | assert({}, 16, Date.now(), 0, 0); 39 | }); 40 | test('single rule', () => { 41 | assert({ baseRule }, 600, Date.now(), 0, 10); 42 | assert({ baseRule }, 605, Date.now(), 0, 0); 43 | }); 44 | test('dependent rule', () => { 45 | const ruleA: Rule = ({ product }) => { 46 | const priceInCents = product.getPriceInCents(); 47 | if (priceInCents < 1000) { 48 | const discount = new Discount(); 49 | discount.setPct(3); 50 | discount.setValueInCents(priceInCents * 0.03); 51 | return discount; 52 | } 53 | return null; 54 | }; 55 | const biggest: Rule = ({ previous }) => { 56 | const discounts = Object.values(previous).filter(Boolean) as Discount[]; 57 | return discounts.reduce((acc, current) => 58 | current.getPct() >= acc.getPct() ? current : acc 59 | ); 60 | }; 61 | assert({ baseRule, ruleA, biggest }, 100, Date.now(), 3, 3); 62 | assert({ baseRule, ruleA, biggest }, 10000, Date.now(), 0, 10); 63 | }); 64 | -------------------------------------------------------------------------------- /apps/discounts/src/services/__tests__/rules.spec.ts: -------------------------------------------------------------------------------- 1 | import * as dateMock from 'jest-date-mock'; 2 | import { Dictionary } from 'ts-essentials'; 3 | import { Discount } from '@hash/protos/dist/discounts_pb'; 4 | import { Product } from '@hash/protos/dist/products_pb'; 5 | import { User } from '@hash/protos/dist/users_pb'; 6 | import config from '../../config'; 7 | import createRules from '../rules'; 8 | 9 | const createContext = ( 10 | priceInCents: number, 11 | dateOfBirth: number | undefined, 12 | previous: Dictionary 13 | ) => { 14 | const product = new Product(); 15 | product.setPriceInCents(priceInCents); 16 | 17 | if (dateOfBirth) { 18 | const user = new User(); 19 | user.setDateOfBirth(dateOfBirth); 20 | return { product, user, previous }; 21 | } 22 | 23 | return { product, previous }; 24 | }; 25 | const assert = (discount: Discount, value: number, percentage: number) => { 26 | expect(discount).toBeInstanceOf(Discount); 27 | expect(discount.getPct()).toBe(percentage); 28 | expect(discount.getValueInCents()).toBe(value * (percentage / 100)); 29 | }; 30 | const rules = createRules({ config }); 31 | 32 | beforeEach(() => { 33 | dateMock.clear(); 34 | }); 35 | describe('birthday', () => { 36 | const { percentage } = config.discount.birthday; 37 | 38 | test('is birthday', () => { 39 | const context = createContext(1300, Date.now(), {}); 40 | const discount = rules.birthday(context) as Discount; 41 | assert(discount, 1300, percentage); 42 | }); 43 | test('not birthday', () => { 44 | dateMock.advanceTo(new Date('14-09-2019')); 45 | 46 | const birthday = new Date('11-01-1999').getTime(); 47 | const context = createContext(1000, birthday, {}); 48 | const discount = rules.birthday(context) as Discount; 49 | expect(discount).toBeNull(); 50 | }); 51 | test('missing user', () => { 52 | const context = createContext(1000, undefined, {}); 53 | const discount = rules.birthday(context) as Discount; 54 | expect(discount).toBeNull(); 55 | }); 56 | }); 57 | describe('blackfriday', () => { 58 | const { percentage, day } = config.discount.blackfriday; 59 | 60 | test('is blackfriday', () => { 61 | dateMock.advanceTo(day); 62 | 63 | const context = createContext(1600, Date.now(), {}); 64 | const discount = rules.blackfriday(context) as Discount; 65 | assert(discount, 1600, percentage); 66 | }); 67 | test('not blackfriday', () => { 68 | dateMock.advanceTo(new Date('14-09-2019')); 69 | 70 | const context = createContext(1500, Date.now(), {}); 71 | const result = rules.blackfriday(context) as Discount; 72 | expect(result).toBeNull(); 73 | }); 74 | }); 75 | describe('maxPercantage', () => { 76 | const { percentage } = config.discount.max; 77 | 78 | test('no previous', () => { 79 | const context = createContext(1000, Date.now(), {}); 80 | const result = rules.maxPercentage(context) as Discount; 81 | expect(result.getPct()).toBe(0); 82 | }); 83 | test('multiple previous', () => { 84 | const price = 1000; 85 | const potato = new Discount(); 86 | potato.setPct(2); 87 | potato.setValueInCents(price * 0.02); 88 | 89 | const orange = new Discount(); 90 | orange.setPct(3); 91 | orange.setValueInCents(price * 0.03); 92 | 93 | const context = createContext(price, Date.now(), { 94 | potato, 95 | nothing: null, 96 | orange, 97 | }); 98 | const discount = rules.maxPercentage(context) as Discount; 99 | 100 | assert(discount, price, 3); 101 | }); 102 | test('over limit discount', () => { 103 | const price = 2000; 104 | const pct = percentage * 2; 105 | 106 | const apple = new Discount(); 107 | apple.setPct(pct); 108 | apple.setValueInCents(price * (pct / 100)); 109 | 110 | const context = createContext(price, Date.now(), { apple }); 111 | const discount = rules.maxPercentage(context) as Discount; 112 | 113 | assert(discount, price, percentage); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /apps/discounts/src/services/findDiscounts.ts: -------------------------------------------------------------------------------- 1 | import { Discount } from '@hash/protos/dist/discounts_pb'; 2 | import * as UserModel from '../models/user'; 3 | import * as ProductModel from '../models/product'; 4 | import { MakeDiscount } from './makeDiscount'; 5 | 6 | type Pair = { productId: string; userId: string }; 7 | export type FindDiscounts = (pairs: Pair[]) => Promise; 8 | 9 | interface DI { 10 | services: { 11 | makeDiscount: MakeDiscount; 12 | }; 13 | models: { 14 | users: { findByIds: UserModel.FindByIds }; 15 | products: { findByIds: ProductModel.FindByIds }; 16 | }; 17 | } 18 | export default ({ services, models }: DI) => { 19 | const findDiscounts: FindDiscounts = async pairs => { 20 | const productIds = pairs.map(pair => pair.productId); 21 | const userIds = pairs.map(pair => pair.userId); 22 | 23 | const [productsById, usersById] = await Promise.all([ 24 | models.products.findByIds(productIds), 25 | models.users.findByIds(userIds), 26 | ]); 27 | 28 | return pairs.map(({ productId, userId }) => { 29 | const product = productsById[productId]; 30 | const user = usersById[userId]; 31 | 32 | if (product) { 33 | return services.makeDiscount(product, user); 34 | } 35 | return new Discount(); 36 | }); 37 | }; 38 | 39 | return findDiscounts; 40 | }; 41 | -------------------------------------------------------------------------------- /apps/discounts/src/services/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary } from 'ts-essentials'; 2 | import { User } from '@hash/protos/dist/users_pb'; 3 | import { Product } from '@hash/protos/dist/products_pb'; 4 | import { Discount } from '@hash/protos/dist/discounts_pb'; 5 | 6 | export interface Context { 7 | user?: User; 8 | product: Product; 9 | previous: Dictionary; 10 | } 11 | -------------------------------------------------------------------------------- /apps/discounts/src/services/makeDiscount.ts: -------------------------------------------------------------------------------- 1 | import R from 'ramda'; 2 | import { User } from '@hash/protos/dist/users_pb'; 3 | import { Product } from '@hash/protos/dist/products_pb'; 4 | import { Discount } from '@hash/protos/dist/discounts_pb'; 5 | import { Context } from './interfaces'; 6 | import { Rules } from './rules'; 7 | 8 | export type MakeDiscount = (product: Product, user?: User) => Discount; 9 | 10 | interface DI { 11 | services: { 12 | rules: Rules; 13 | }; 14 | } 15 | export default ({ services }: DI) => { 16 | const makeDiscount: MakeDiscount = (product, user) => { 17 | const initial: Context = { product, user, previous: {} }; 18 | const { previous } = Object.entries(services.rules).reduce( 19 | (context, [key, rule]) => ({ 20 | ...context, 21 | previous: { 22 | ...context.previous, 23 | [key]: rule(context), 24 | }, 25 | }), 26 | initial 27 | ); 28 | return R.last(Object.values(previous)) || new Discount(); 29 | }; 30 | return makeDiscount; 31 | }; 32 | -------------------------------------------------------------------------------- /apps/discounts/src/services/rules.ts: -------------------------------------------------------------------------------- 1 | import { isToday } from 'date-fns'; 2 | import { Dictionary } from 'ts-essentials'; 3 | import { Product } from '@hash/protos/dist/products_pb'; 4 | import { Discount } from '@hash/protos/dist/discounts_pb'; 5 | import { Config } from '../config'; 6 | import { Context } from './interfaces'; 7 | 8 | export type Rule = (context: Context) => Discount | null; 9 | export type Rules = Dictionary; 10 | 11 | interface DI { 12 | config: { 13 | discount: Config['discount']; 14 | }; 15 | } 16 | export default ({ config }: DI) => { 17 | const createDiscount = (product: Product, percentage: number) => { 18 | const priceInCents = product.getPriceInCents(); 19 | const discount = new Discount(); 20 | 21 | const discountValue = Math.floor(priceInCents * (percentage / 100)); 22 | discount.setPct(percentage); 23 | discount.setValueInCents(discountValue); 24 | return discount; 25 | }; 26 | 27 | const birthday: Rule = ({ user, product }) => { 28 | if (!user) { 29 | return null; 30 | } 31 | 32 | const PERCENTAGE = config.discount.birthday.percentage; 33 | const dateOfBirth = user.getDateOfBirth(); 34 | return isToday(dateOfBirth) ? createDiscount(product, PERCENTAGE) : null; 35 | }; 36 | const blackfriday: Rule = ({ product }) => { 37 | const PERCENTAGE = config.discount.blackfriday.percentage; 38 | const BLACKFRIDAY = config.discount.blackfriday.day; 39 | return isToday(BLACKFRIDAY) ? createDiscount(product, PERCENTAGE) : null; 40 | }; 41 | const maxPercentage: Rule = ({ product, previous }) => { 42 | const MAX_PERCENTAGE = config.discount.max.percentage; 43 | 44 | const previousPercentage = Object.values(previous) 45 | .map(discount => (discount ? discount.getPct() : 0)) 46 | .reduce((a, b) => Math.max(a, b), 0); 47 | 48 | const percentage = Math.min(MAX_PERCENTAGE, previousPercentage); 49 | return createDiscount(product, percentage); 50 | }; 51 | 52 | // all engines guarantee order for non numeric index 53 | return { birthday, blackfriday, maxPercentage }; 54 | }; 55 | -------------------------------------------------------------------------------- /apps/discounts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "tests"], 3 | "compilerOptions": { 4 | /* Basic Options */ 5 | "incremental": true /* Enable incremental compilation */, 6 | "target": "ES2019" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 7 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 8 | // "lib": [], /* Specify library files to be included in the compilation. */ 9 | "allowJs": true /* Allow javascript files to be compiled. */, 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | "sourceMap": true /* Generates corresponding '.map' file. */, 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | "outDir": "./dist" /* Redirect output structure to the directory. */, 17 | "rootDir": "./" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 18 | // "composite": true, /* Enable project compilation */ 19 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 20 | // "removeComments": true, /* Do not emit comments to output. */ 21 | // "noEmit": true, /* Do not emit outputs. */ 22 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 23 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | 26 | /* Strict Type-Checking Options */ 27 | "strict": true /* Enable all strict type-checking options. */, 28 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 32 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 33 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | 36 | /* Additional Checks */ 37 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 38 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 39 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 40 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 41 | 42 | /* Module Resolution Options */ 43 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 44 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 45 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 46 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 47 | // "typeRoots": [], /* List of folders to include type definitions from. */ 48 | // "types": [], /* Type declaration files to be included in compilation. */ 49 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 50 | "resolveJsonModule": true, 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 63 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /apps/products/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 as protos 2 | 3 | RUN mkdir -p /protos 4 | WORKDIR /protos 5 | 6 | COPY package.json yarn.lock ./ 7 | COPY packages/protos/package.json ./packages/protos/package.json 8 | RUN yarn --pure-lockfile 9 | 10 | COPY packages/protos /protos/packages/protos 11 | WORKDIR /protos/packages/protos 12 | RUN yarn build 13 | 14 | RUN NODE_ENV=production yarn --pure-lockfile 15 | 16 | FROM golang:1.13-alpine as build 17 | 18 | RUN mkdir -p /build 19 | COPY --from=protos /protos/packages/protos/dist /build/packages/protos/dist 20 | COPY go.mod go.sum /build/ 21 | 22 | COPY apps/products /build/apps/products 23 | 24 | WORKDIR /build/apps/products 25 | RUN go build 26 | 27 | FROM alpine 28 | 29 | RUN mkdir -p /deploy 30 | COPY --from=build /build/apps/products/products /deploy/products 31 | 32 | CMD ["/deploy/products"] 33 | -------------------------------------------------------------------------------- /apps/products/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Config struct { 4 | ListenHTTP string 5 | ListenGRPC string 6 | 7 | DiscountService string 8 | 9 | DBAddr string 10 | DBUser string 11 | DBPassword string 12 | DBName string 13 | } 14 | 15 | var DefaultConfig = Config{ 16 | ListenHTTP: ":8080", 17 | ListenGRPC: ":50051", 18 | 19 | DiscountService: "api-discounts:50051", 20 | 21 | DBAddr: "db-products:5432", 22 | DBUser: "postgres", 23 | DBPassword: "ai_tem_de_mudar_isso_aqui", 24 | DBName: "products", 25 | } 26 | -------------------------------------------------------------------------------- /apps/products/http/server.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "github.com/EduardoRFS/hash-test/apps/products/models" 5 | "github.com/EduardoRFS/hash-test/apps/products/services" 6 | pb "github.com/EduardoRFS/hash-test/packages/protos/dist" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/go-pg/pg/v9" 10 | ) 11 | 12 | type productsServer struct { 13 | db *pg.DB 14 | service *services.ProductService 15 | } 16 | 17 | func (s *productsServer) GetProduct(c *gin.Context) { 18 | userID := c.GetHeader("X-USER-ID") 19 | products, err := models.ListProducts(s.db) 20 | if err != nil { 21 | panic(err) 22 | } 23 | 24 | protos := make([]*pb.Product, len(products)) 25 | 26 | for i, p := range products { 27 | protos[i] = models.ProductToProto(p) 28 | } 29 | s.service.LoadDiscounts(protos, &userID) 30 | c.JSON(200, protos) 31 | } 32 | func StartProductServer(db *pg.DB, discountClient pb.DiscountsServiceClient, listen string) { 33 | productServer := &productsServer{ 34 | db: db, 35 | service: services.NewProductService(discountClient), 36 | } 37 | r := gin.Default() 38 | r.GET("/product", productServer.GetProduct) 39 | err := r.Run(listen) 40 | if err != nil { 41 | panic(err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /apps/products/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/EduardoRFS/hash-test/apps/products/config" 7 | "github.com/EduardoRFS/hash-test/apps/products/http" 8 | "github.com/EduardoRFS/hash-test/apps/products/rpc" 9 | 10 | pb "github.com/EduardoRFS/hash-test/packages/protos/dist" 11 | 12 | "github.com/go-pg/pg/v9" 13 | "google.golang.org/grpc" 14 | ) 15 | 16 | type App struct { 17 | config config.Config 18 | db *pg.DB 19 | discountClient pb.DiscountsServiceClient 20 | } 21 | 22 | func (app *App) registerConfig(config config.Config) { 23 | app.config = config 24 | } 25 | func (app *App) startDB() { 26 | app.db = pg.Connect(&pg.Options{ 27 | Addr: app.config.DBAddr, 28 | User: app.config.DBUser, 29 | Password: app.config.DBPassword, 30 | Database: app.config.DBName, 31 | }) 32 | } 33 | func (app *App) closeDB() { 34 | app.db.Close() 35 | } 36 | func (app *App) startDiscountClient() { 37 | opts := grpc.WithInsecure() 38 | conn, err := grpc.Dial(app.config.DiscountService, opts) 39 | 40 | if err != nil { 41 | panic(err) 42 | } 43 | 44 | app.discountClient = pb.NewDiscountsServiceClient(conn) 45 | } 46 | func (app *App) startGRPC() { 47 | lis := rpc.StartProductServer(app.db, app.discountClient, app.config.ListenGRPC) 48 | defer lis.Close() 49 | } 50 | func (app *App) startHTTP() { 51 | http.StartProductServer(app.db, app.discountClient, app.config.ListenHTTP) 52 | } 53 | func main() { 54 | app := &App{} 55 | 56 | app.registerConfig(config.DefaultConfig) 57 | app.startDB() 58 | defer app.closeDB() 59 | app.startDiscountClient() 60 | 61 | var wg sync.WaitGroup 62 | wg.Add(1) 63 | 64 | go func() { 65 | defer wg.Done() 66 | app.startGRPC() 67 | }() 68 | go func() { 69 | defer wg.Done() 70 | app.startHTTP() 71 | }() 72 | 73 | wg.Wait() 74 | } 75 | -------------------------------------------------------------------------------- /apps/products/migrations/1_Bootstrap.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-pg/migrations/v7" 7 | ) 8 | 9 | func init() { 10 | migrations.MustRegisterTx(func(db migrations.DB) error { 11 | fmt.Println("creating table product...") 12 | _, err := db.Exec(` 13 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 14 | CREATE TABLE "product" ( 15 | "created_at" TIMESTAMP NOT NULL DEFAULT now(), 16 | "id" uuid NOT NULL DEFAULT uuid_generate_v4(), 17 | "price_in_cents" numeric NOT NULL, 18 | "title" text NOT NULL, 19 | "description" text NOT NULL, 20 | PRIMARY KEY ("id") 21 | ); 22 | INSERT INTO product (created_at, id, price_in_cents, title, description) VALUES('2019-09-21 23:51:00.425', '33687aa8-0537-469a-8b11-410e5681d49a', 1256, 'Desu', 'Ne'); 23 | INSERT INTO product (created_at, id, price_in_cents, title, description) VALUES('2019-09-22 22:39:48.200', '4b52538f-ac0b-4ef3-b86f-9ca5505a0d56', 5432, 'ABC', 'YZ'); 24 | INSERT INTO product (created_at, id, price_in_cents, title, description) VALUES('2019-09-22 22:39:48.212', '69f15755-bab3-4924-81da-9547f921cfea', 47658, 'DEF', 'VWX'); 25 | INSERT INTO product (created_at, id, price_in_cents, title, description) VALUES('2019-09-22 22:39:48.214', 'a97038af-77cd-4151-b6b2-306e4e480180', 4321, 'GHI', 'STU'); 26 | INSERT INTO product (created_at, id, price_in_cents, title, description) VALUES('2019-09-22 22:39:48.216', '5fe101f0-e3f3-43ca-97a0-018a89f5c8f4', 1234, 'JKL', 'PQR'); 27 | INSERT INTO product (created_at, id, price_in_cents, title, description) VALUES('2019-09-22 22:39:48.218', 'c408f06a-8033-4dc6-b158-0d8230449d1a', 7456, 'MNO', 'MNO'); 28 | INSERT INTO product (created_at, id, price_in_cents, title, description) VALUES('2019-09-22 22:39:48.220', 'a0cc835b-cf49-43e9-b549-057e605b5f8a', 3456, 'PQR', 'JKL'); 29 | INSERT INTO product (created_at, id, price_in_cents, title, description) VALUES('2019-09-22 22:39:48.222', '59494795-74d3-4251-aaa9-ec5cfeea9a06', 8769, 'STU', 'GHI'); 30 | INSERT INTO product (created_at, id, price_in_cents, title, description) VALUES('2019-09-22 22:39:48.223', '03d0c137-cdc2-4f9d-8bb9-fe2390e81d97', 2134, 'VWX', 'DEF'); 31 | INSERT INTO product (created_at, id, price_in_cents, title, description) VALUES('2019-09-22 22:39:48.225', 'caeba849-305c-460d-9cc1-64d06e9a6c35', 543634, 'YZ', 'ABC'); 32 | `) 33 | return err 34 | }, func(db migrations.DB) error { 35 | fmt.Println("dropping table product...") 36 | _, err := db.Exec(`DROP TABLE "product"`) 37 | return err 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /apps/products/migrations/run.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/EduardoRFS/hash-test/apps/products/config" 9 | 10 | "github.com/go-pg/migrations/v7" 11 | "github.com/go-pg/pg/v9" 12 | ) 13 | 14 | const usageText = `This program runs command on the db. Supported commands are: 15 | - init - creates version info table in the database 16 | - up - runs all available migrations. 17 | - up [target] - runs available migrations up to the target one. 18 | - down - reverts last migration. 19 | - reset - reverts all migrations. 20 | - version - prints current db version. 21 | - set_version [version] - sets db version without running migrations. 22 | Usage: 23 | go run *.go [args] 24 | ` 25 | 26 | func main() { 27 | flag.Usage = usage 28 | flag.Parse() 29 | 30 | config := config.DefaultConfig 31 | 32 | db := pg.Connect(&pg.Options{ 33 | Addr: config.DBAddr, 34 | User: config.DBUser, 35 | Password: config.DBPassword, 36 | Database: config.DBName, 37 | }) 38 | 39 | oldVersion, newVersion, err := migrations.Run(db, flag.Args()...) 40 | if err != nil { 41 | exitf(err.Error()) 42 | } 43 | if newVersion != oldVersion { 44 | fmt.Printf("migrated from version %d to %d\n", oldVersion, newVersion) 45 | } else { 46 | fmt.Printf("version is %d\n", oldVersion) 47 | } 48 | } 49 | 50 | func usage() { 51 | fmt.Print(usageText) 52 | flag.PrintDefaults() 53 | os.Exit(2) 54 | } 55 | 56 | func errorf(s string, args ...interface{}) { 57 | fmt.Fprintf(os.Stderr, s+"\n", args...) 58 | } 59 | 60 | func exitf(s string, args ...interface{}) { 61 | errorf(s, args...) 62 | os.Exit(1) 63 | } 64 | -------------------------------------------------------------------------------- /apps/products/models/discount.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | 6 | "context" 7 | "errors" 8 | "time" 9 | 10 | pb "github.com/EduardoRFS/hash-test/packages/protos/dist" 11 | "google.golang.org/genproto/googleapis/rpc/code" 12 | ) 13 | 14 | func FindDiscounts(client pb.DiscountsServiceClient, productIds []string, userId *string) ([]*pb.Discount, error) { 15 | makeRequests := func() []*pb.DiscountRequest { 16 | requests := make([]*pb.DiscountRequest, len(productIds)) 17 | for i, v := range productIds { 18 | request := &pb.DiscountRequest{ProductId: v} 19 | if userId != nil { 20 | request.UserId = *userId 21 | } 22 | requests[i] = request 23 | } 24 | return requests 25 | } 26 | 27 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) 28 | defer cancel() 29 | 30 | requests := makeRequests() 31 | response, err := client.FindDiscounts(ctx, &pb.FindDiscountsRequest{Requests: requests}) 32 | 33 | if err != nil { 34 | fmt.Print(err) 35 | return nil, err 36 | } 37 | 38 | status := response.Status 39 | if status.Code != code.Code_value["OK"] { 40 | return nil, errors.New(status.Message) 41 | } 42 | return response.Discount, nil 43 | } 44 | -------------------------------------------------------------------------------- /apps/products/models/product.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | pb "github.com/EduardoRFS/hash-test/packages/protos/dist" 7 | 8 | "github.com/go-pg/pg/v9" 9 | uuid "github.com/satori/go.uuid" 10 | ) 11 | 12 | func mapStringToUuid(ids []string) ([]uuid.UUID, error) { 13 | uids := make([]uuid.UUID, len(ids)) 14 | for i, v := range ids { 15 | uid, err := uuid.FromString(v) 16 | 17 | if err != nil { 18 | return nil, err 19 | } 20 | uids[i] = uid 21 | } 22 | return uids, nil 23 | } 24 | 25 | type Product struct { 26 | tableName struct{} `sql:"product"` 27 | CreatedAt time.Time `json:"created_at"` 28 | ID uuid.UUID `json:"id", sql:"type:uuid"` 29 | PriceInCents uint64 `json:"price_in_cents"` 30 | Title string `json:"title"` 31 | Description string `json:"description"` 32 | } 33 | 34 | func CreateProduct(db *pg.DB, p *Product) error { 35 | return db.Insert(p) 36 | } 37 | func ListProducts(db *pg.DB) ([]*Product, error) { 38 | var products []*Product 39 | err := db.Model(&products).Select() 40 | if err != nil { 41 | return nil, err 42 | } 43 | if products == nil { 44 | return []*Product{}, nil 45 | } 46 | return products, nil 47 | } 48 | func FindProductByIds(db *pg.DB, ids []string) ([]*Product, error) { 49 | uids, err := mapStringToUuid(ids) 50 | 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | products := make([]*Product, len(ids)) 56 | 57 | err = db.Model(&products).WhereIn("id IN (?)", uids).Select() 58 | 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | return products, nil 64 | } 65 | func FindProductById(db *pg.DB, id string) (*Product, error) { 66 | products, err := FindProductByIds(db, []string{id}) 67 | 68 | if err != nil { 69 | return nil, err 70 | } 71 | if len(products) == 0 { 72 | return nil, nil 73 | } 74 | return products[0], nil 75 | } 76 | func ProductToProto(p *Product) *pb.Product { 77 | return &pb.Product{ 78 | Id: p.ID.String(), 79 | PriceInCents: p.PriceInCents, 80 | Title: p.Title, 81 | Description: p.Description, 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /apps/products/rpc/server.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "context" 5 | "net" 6 | 7 | "github.com/EduardoRFS/hash-test/apps/products/models" 8 | "github.com/EduardoRFS/hash-test/apps/products/services" 9 | 10 | pb "github.com/EduardoRFS/hash-test/packages/protos/dist" 11 | "github.com/go-pg/pg/v9" 12 | "google.golang.org/genproto/googleapis/rpc/code" 13 | "google.golang.org/genproto/googleapis/rpc/status" 14 | "google.golang.org/grpc" 15 | ) 16 | 17 | type productsServer struct { 18 | db *pg.DB 19 | service *services.ProductService 20 | } 21 | 22 | func (s *productsServer) CreateProduct(ctx context.Context, req *pb.CreateProductRequest) (*pb.CreateProductResponse, error) { 23 | product := &models.Product{ 24 | PriceInCents: req.PriceInCents, 25 | Title: req.Title, 26 | Description: req.Description, 27 | } 28 | 29 | err := models.CreateProduct(s.db, product) 30 | if err != nil { 31 | return &pb.CreateProductResponse{ 32 | Status: &status.Status{ 33 | Code: code.Code_value["INTERNAL"], 34 | Message: err.Error(), 35 | }, 36 | }, nil 37 | } 38 | 39 | return &pb.CreateProductResponse{ 40 | Status: &status.Status{ 41 | Code: code.Code_value["OK"], 42 | }, 43 | Product: models.ProductToProto(product), 44 | }, nil 45 | } 46 | 47 | func (s *productsServer) ReadProduct(ctx context.Context, req *pb.ReadProductRequest) (*pb.ReadProductResponse, error) { 48 | product, err := models.FindProductById(s.db, req.Id) 49 | if err != nil { 50 | return &pb.ReadProductResponse{ 51 | Status: &status.Status{ 52 | Code: code.Code_value["INTERNAL"], 53 | Message: err.Error(), 54 | }, 55 | }, nil 56 | } 57 | 58 | return &pb.ReadProductResponse{ 59 | Status: &status.Status{ 60 | Code: code.Code_value["OK"], 61 | }, 62 | Product: models.ProductToProto(product), 63 | }, nil 64 | } 65 | 66 | func (s *productsServer) ListProducts(ctx context.Context, req *pb.ListProductsRequest) (*pb.ListProductsResponse, error) { 67 | var products []*models.Product 68 | var err error 69 | if req.Id == nil { 70 | products, err = models.ListProducts(s.db) 71 | } else { 72 | products, err = models.FindProductByIds(s.db, req.Id) 73 | } 74 | 75 | if err != nil { 76 | return &pb.ListProductsResponse{ 77 | Status: &status.Status{ 78 | Code: code.Code_value["INTERNAL"], 79 | Message: err.Error(), 80 | }, 81 | }, nil 82 | } 83 | 84 | protos := make([]*pb.Product, len(products)) 85 | for i, v := range products { 86 | protos[i] = models.ProductToProto(v) 87 | } 88 | 89 | return &pb.ListProductsResponse{ 90 | Status: &status.Status{ 91 | Code: code.Code_value["OK"], 92 | }, 93 | Products: protos, 94 | }, nil 95 | } 96 | 97 | func StartProductServer(db *pg.DB, discountClient pb.DiscountsServiceClient, listen string) net.Listener { 98 | server := grpc.NewServer() 99 | productServer := &productsServer{ 100 | db: db, service: services.NewProductService(discountClient)} 101 | pb.RegisterProductsServiceServer(server, productServer) 102 | 103 | lis, err := net.Listen("tcp", listen) 104 | if err != nil { 105 | panic(lis) 106 | } 107 | server.Serve(lis) 108 | return lis 109 | } 110 | -------------------------------------------------------------------------------- /apps/products/services/product.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "github.com/EduardoRFS/hash-test/apps/products/models" 5 | pb "github.com/EduardoRFS/hash-test/packages/protos/dist" 6 | 7 | uuid "github.com/satori/go.uuid" 8 | ) 9 | 10 | func mapStringToUuid(ids []string) ([]uuid.UUID, error) { 11 | uids := make([]uuid.UUID, len(ids)) 12 | for i, v := range ids { 13 | uid, err := uuid.FromString(v) 14 | 15 | if err != nil { 16 | return nil, err 17 | } 18 | uids[i] = uid 19 | } 20 | return uids, nil 21 | } 22 | 23 | type ProductService struct { 24 | discountClient pb.DiscountsServiceClient 25 | } 26 | 27 | func (s *ProductService) LoadDiscount(product *pb.Product, userID *string) error { 28 | return s.LoadDiscounts([]*pb.Product{product}, userID) 29 | } 30 | func (s *ProductService) LoadDiscounts(products []*pb.Product, userID *string) error { 31 | ids := make([]string, len(products)) 32 | 33 | for i, p := range products { 34 | ids[i] = p.Id 35 | } 36 | 37 | discounts, err := models.FindDiscounts(s.discountClient, ids, userID) 38 | 39 | for i, p := range products { 40 | if err == nil || len(discounts) > i { 41 | discount := discounts[i] 42 | if discount.Pct != 0 || discount.ValueInCents != 0 { 43 | p.Discount = discounts[i] 44 | } 45 | } 46 | } 47 | 48 | return err 49 | } 50 | 51 | func NewProductService(discountClient pb.DiscountsServiceClient) *ProductService { 52 | return &ProductService{discountClient} 53 | } 54 | -------------------------------------------------------------------------------- /apps/users/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { project: './tsconfig.json' }, 3 | extends: [ 4 | 'airbnb-typescript/base', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 7 | 'plugin:prettier/recommended', 8 | 'prettier/@typescript-eslint', 9 | ], 10 | rules: { 11 | '@typescript-eslint/no-floating-promises': 'error', 12 | 'class-methods-use-this': 0, 13 | '@typescript-eslint/explicit-function-return-type': 0, 14 | 'import/no-cycle': 0, 15 | 'import/prefer-default-export': 0, 16 | 'no-restricted-syntax': 0, 17 | }, 18 | overrides: [ 19 | { 20 | files: ['**/__tests__/**/*.ts', '*.spec.ts'], 21 | extends: ['plugin:jest/recommended'], 22 | rules: { 23 | 'jest/valid-describe': 0, 24 | 'no-shadow': 0, 25 | '@typescript-eslint/no-explicit-any': 0, 26 | }, 27 | }, 28 | ], 29 | }; 30 | -------------------------------------------------------------------------------- /apps/users/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 as build 2 | 3 | RUN mkdir -p /build 4 | WORKDIR /build 5 | 6 | # cache deps 7 | COPY package.json yarn.lock ./ 8 | COPY packages/utils/package.json ./packages/utils/package.json 9 | COPY packages/protos/package.json ./packages/protos/package.json 10 | COPY apps/users/package.json ./apps/users/package.json 11 | RUN yarn --pure-lockfile 12 | 13 | # proto 14 | COPY packages/protos /build/packages/protos 15 | WORKDIR /build/packages/protos 16 | RUN yarn build 17 | 18 | # utils 19 | COPY packages/utils /build/packages/utils 20 | WORKDIR /build/packages/utils 21 | RUN yarn build 22 | 23 | # users 24 | COPY apps/users /build/apps/users 25 | WORKDIR /build/apps/users 26 | RUN yarn build 27 | 28 | # clean deps 29 | WORKDIR /build 30 | RUN NODE_ENV=production yarn --pure-lockfile 31 | 32 | FROM node:12-alpine 33 | 34 | RUN mkdir -p /deploy 35 | COPY --from=build /build /deploy 36 | 37 | WORKDIR /deploy/apps/users 38 | 39 | CMD ["yarn", "start"] 40 | -------------------------------------------------------------------------------- /apps/users/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hash/users", 3 | "version": "0.0.0", 4 | "main": "dist/src/index.js", 5 | "author": "EduardoRFS ", 6 | "license": "MIT", 7 | "private": true, 8 | "engines": { 9 | "node": ">=10.9" 10 | }, 11 | "scripts": { 12 | "test": "jest --coverage", 13 | "build": "tsc", 14 | "typeorm": "./typeorm.sh", 15 | "migration:run": "yarn typeorm migration:run", 16 | "start": "NODE_ENV=production node dist/src/index.js" 17 | }, 18 | "devDependencies": { 19 | "@types/date-fns": "^2.6.0", 20 | "@types/jest": "^24.0.16", 21 | "@types/lru-cache": "^5.1.0", 22 | "@types/ramda": "^0.26.18", 23 | "@types/uuid": "^3.4.5", 24 | "@typescript-eslint/eslint-plugin": "^2.0.0", 25 | "@typescript-eslint/parser": "^1.13.0", 26 | "eslint": "^6.1.0", 27 | "eslint-config-airbnb-base": "^13.2.0", 28 | "eslint-config-airbnb-typescript": "^4.0.1", 29 | "eslint-config-prettier": "^6.0.0", 30 | "eslint-plugin-import": "^2.18.2", 31 | "eslint-plugin-jest": "^22.14.1", 32 | "eslint-plugin-prettier": "^3.1.0", 33 | "jest": "^24.8.0", 34 | "jest-with-context": "^1.1.1", 35 | "prettier": "^1.18.2", 36 | "ts-jest": "^24.0.2", 37 | "typescript": "^3.5.3", 38 | "uuid": "^3.3.2" 39 | }, 40 | "dependencies": { 41 | "@hash/protos": "0.0.0", 42 | "@hash/utils": "0.0.0", 43 | "@malijs/compose": "^1.3.2", 44 | "date-fns": "^2.2.1", 45 | "grpc": "https://github.com/EduardoRFS/grpc-native-core.git#20ba246427f179d5eaaf99bcca893fd7ccc29689", 46 | "mali": "https://github.com/EduardoRFS/mali.git#b7de8855b5413b65f41a4943f6b4c9f2c12fd425", 47 | "pg": "^7.12.1", 48 | "ramda": "^0.26.1", 49 | "ts-essentials": "^3.0.2", 50 | "typeorm": "^0.2.18", 51 | "typeorm-naming-strategies": "^1.1.0", 52 | "yup": "^0.27.0" 53 | }, 54 | "prettier": { 55 | "tabWidth": 2, 56 | "singleQuote": true, 57 | "trailingComma": "es5" 58 | }, 59 | "jest": { 60 | "roots": [ 61 | "/src", 62 | "/tests" 63 | ], 64 | "preset": "ts-jest/presets/js-with-ts" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /apps/users/src/app.ts: -------------------------------------------------------------------------------- 1 | import Mali from 'mali'; 2 | import * as services from '@hash/protos/dist/users_grpc_pb'; 3 | import { logger } from '@hash/utils'; 4 | import dependencies from './dependencies'; 5 | import readUser from './controllers/readUser'; 6 | import createUser from './controllers/createUser'; 7 | import listUsers from './controllers/listUsers'; 8 | 9 | const app = new Mali(services); 10 | 11 | app.use(logger(console)); 12 | 13 | app.use('createUser', createUser(dependencies)); 14 | app.use('readUser', readUser(dependencies)); 15 | app.use('listUsers', listUsers(dependencies)); 16 | 17 | export default app; 18 | -------------------------------------------------------------------------------- /apps/users/src/config/index.ts: -------------------------------------------------------------------------------- 1 | import { NamingStrategyInterface } from 'typeorm'; 2 | import ormconfig from './ormconfig'; 3 | 4 | interface ORMConfig { 5 | type: 'postgres'; 6 | database: string; 7 | host: string; 8 | port: number; 9 | username: string; 10 | password: string; 11 | namingStrategy: NamingStrategyInterface; 12 | entities: Function[]; 13 | migrations: string[]; 14 | cli: { migrationsDir: string }; 15 | } 16 | export interface Config { 17 | listen: string; 18 | database: ORMConfig; 19 | cache: { 20 | maxAge: number; 21 | maxSize: number; // entries 22 | }; 23 | idLength: number; 24 | minFirstName: number; 25 | minLastName: number; 26 | minDateOfBirth: number; 27 | } 28 | 29 | const config: Config = { 30 | listen: '0.0.0.0:50051', 31 | database: ormconfig, 32 | cache: { 33 | maxAge: 1000 * 60 * 60, // 1h 34 | maxSize: 10240, 35 | }, 36 | idLength: 36, 37 | minFirstName: 2, 38 | minLastName: 2, 39 | minDateOfBirth: -2208977612000, // 01-01-1900 40 | }; 41 | 42 | export default config; 43 | -------------------------------------------------------------------------------- /apps/users/src/config/ormconfig.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; 3 | import { User } from '../models/User'; 4 | 5 | export = { 6 | type: 'postgres' as const, 7 | database: 'users', 8 | host: 'db-users', 9 | port: 5432, 10 | username: 'postgres', 11 | password: 'ai_tem_de_mudar_isso_aqui', 12 | namingStrategy: new SnakeNamingStrategy(), 13 | entities: [User], 14 | migrations: [path.resolve(__dirname, '../migrations/*.js')], 15 | cli: { migrationsDir: 'src/migrations' }, 16 | }; 17 | -------------------------------------------------------------------------------- /apps/users/src/controllers/createUser.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import compose from '@malijs/compose'; 3 | import { errorHandler, createValidation, createRespond } from '@hash/utils'; 4 | import { CreateUserResponse } from '@hash/protos/dist/users_pb'; 5 | import { CreateUser } from './interfaces'; 6 | import { Create, toMessage } from '../models/User'; 7 | 8 | interface DI { 9 | create: Create; 10 | config: { 11 | minFirstName: number; 12 | minLastName: number; 13 | minDateOfBirth: number; 14 | }; 15 | } 16 | export default ({ create, config }: DI) => { 17 | const errors = errorHandler(CreateUserResponse); 18 | const validate = createValidation( 19 | CreateUserResponse, 20 | yup.object({ 21 | firstName: yup.string().min(config.minFirstName), 22 | lastName: yup.string().min(config.minLastName), 23 | dateOfBirth: yup 24 | .number() 25 | .moreThan(config.minDateOfBirth) 26 | .notOneOf([0]), 27 | }) 28 | ); 29 | 30 | const { ok } = createRespond(CreateUserResponse); 31 | const createUser: CreateUser = async ctx => { 32 | const model = await create({ 33 | firstName: ctx.req.getFirstName(), 34 | lastName: ctx.req.getLastName(), 35 | dateOfBirth: new Date(ctx.req.getDateOfBirth()), 36 | }); 37 | const user = toMessage(model); 38 | ctx.res = ok(res => res.setUser(user)); 39 | }; 40 | return compose([errors, validate, createUser]); 41 | }; 42 | -------------------------------------------------------------------------------- /apps/users/src/controllers/interfaces.ts: -------------------------------------------------------------------------------- 1 | import Mali from 'mali'; 2 | import * as services from '@hash/protos/dist/users_grpc_pb'; 3 | 4 | type Methods = Mali.Methods; 5 | export type CreateUser = Methods['createUser']; 6 | export type ReadUser = Methods['readUser']; 7 | export type ListUsers = Methods['listUsers']; 8 | -------------------------------------------------------------------------------- /apps/users/src/controllers/listUsers.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import compose from '@malijs/compose'; 3 | import { 4 | errorHandler, 5 | createRespond, 6 | createValidation, 7 | memoize, 8 | } from '@hash/utils'; 9 | import { ListUsersResponse } from '@hash/protos/dist/users_pb'; 10 | import { ListUsers } from './interfaces'; 11 | import { Find, FindByIds, toMessage } from '../models/User'; 12 | 13 | interface DI { 14 | cache: memoize.Cache; 15 | find: Find; 16 | findByIds: FindByIds; 17 | config: { 18 | idLength: number; 19 | }; 20 | } 21 | 22 | export default ({ cache, config, find, findByIds }: DI) => { 23 | const errors = errorHandler(ListUsersResponse); 24 | const validate = createValidation( 25 | ListUsersResponse, 26 | yup.object({ 27 | ids: yup.array().of(yup.string().length(config.idLength)), 28 | }) 29 | ); 30 | 31 | const { ok } = createRespond(ListUsersResponse); 32 | const listUsers: ListUsers = async ctx => { 33 | const ids = ctx.req.getIdList(); 34 | const models = await (ids.length ? findByIds(ids) : find()); 35 | const users = models.map(toMessage); 36 | 37 | ctx.res = ok(res => res.setUsersList(users)); 38 | }; 39 | return memoize(compose([errors, validate, listUsers]), cache); 40 | }; 41 | -------------------------------------------------------------------------------- /apps/users/src/controllers/readUser.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import compose from '@malijs/compose'; 3 | import { 4 | errorHandler, 5 | createRespond, 6 | createValidation, 7 | memoize, 8 | } from '@hash/utils'; 9 | import { ReadUserResponse } from '@hash/protos/dist/users_pb'; 10 | import { ReadUser } from './interfaces'; 11 | import { FindById, toMessage } from '../models/User'; 12 | 13 | interface DI { 14 | cache: memoize.Cache; 15 | findById: FindById; 16 | config: { 17 | idLength: number; 18 | }; 19 | } 20 | 21 | export default ({ cache, config, findById }: DI) => { 22 | const errors = errorHandler(ReadUserResponse); 23 | const validate = createValidation( 24 | ReadUserResponse, 25 | yup.object({ 26 | id: yup.string().length(config.idLength), 27 | }) 28 | ); 29 | 30 | const { ok, notFound } = createRespond(ReadUserResponse); 31 | const readUser: ReadUser = async ctx => { 32 | const id = ctx.req.getId(); 33 | const model = await findById(id); 34 | const user = model && toMessage(model); 35 | 36 | ctx.res = user ? ok(res => res.setUser(user)) : notFound(); 37 | }; 38 | return memoize(compose([errors, validate, readUser]), cache); 39 | }; 40 | -------------------------------------------------------------------------------- /apps/users/src/dependencies.ts: -------------------------------------------------------------------------------- 1 | import { memoize } from '@hash/utils'; 2 | import { create, find, findById, findByIds } from './models/User'; 3 | import config from './config'; 4 | 5 | const dependencies = { 6 | config, 7 | create, 8 | find, 9 | findById, 10 | findByIds, 11 | cache: memoize.cache(config.cache), 12 | }; 13 | export default dependencies; 14 | -------------------------------------------------------------------------------- /apps/users/src/index.ts: -------------------------------------------------------------------------------- 1 | import { createConnection, Connection } from 'typeorm'; 2 | import config from './config'; 3 | import app from './app'; 4 | 5 | const waitForConnection = (): Promise => 6 | createConnection(config.database).catch(waitForConnection); 7 | export default waitForConnection().then(connection => { 8 | const server = app.start(config.listen); 9 | 10 | // eslint-disable-next-line no-console 11 | console.log(`Running at ${config.listen}`); 12 | return { server, connection }; 13 | }); 14 | -------------------------------------------------------------------------------- /apps/users/src/migrations/1568307883445-Bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { format, startOfToday, startOfTomorrow } from 'date-fns'; 2 | import { MigrationInterface, QueryRunner } from 'typeorm'; 3 | 4 | const today = format(startOfToday(), 'yyyy-MM-dd HH:mm:ss.SSS'); 5 | const tomorrow = format(startOfTomorrow(), 'yyyy-MM-dd HH:mm:ss.SSS'); 6 | 7 | export class Bootstrap1568307883445 implements MigrationInterface { 8 | public async up(queryRunner: QueryRunner): Promise { 9 | await queryRunner.query( 10 | ` 11 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 12 | CREATE TABLE "user" ( 13 | "created_at" TIMESTAMP NOT NULL DEFAULT now(), 14 | "id" uuid NOT NULL DEFAULT uuid_generate_v4(), 15 | "first_name" text NOT NULL, 16 | "last_name" text NOT NULL, 17 | "date_of_birth" TIMESTAMP NOT NULL, 18 | CONSTRAINT "PK_cace4a159ff9f2512dd42373760" 19 | PRIMARY KEY ("id") 20 | ); 21 | INSERT INTO "user" (created_at, id, first_name, last_name, date_of_birth) VALUES('2019-09-20 22:42:04.837', '527f5437-c770-4336-b6bd-9e954aa54a35', 'User 0', 'Square 0', '2019-06-01 03:28:45.262'); 22 | INSERT INTO "user" (created_at, id, first_name, last_name, date_of_birth) VALUES('2019-09-20 22:42:04.837', '89edb0af-6a8f-4b4b-bac1-6bf556c7f5a4', 'User 1', 'Square 1', '2001-06-21 01:35:07.815'); 23 | INSERT INTO "user" (created_at, id, first_name, last_name, date_of_birth) VALUES('2019-09-20 22:42:04.837', 'f9769296-4e1e-483e-acb6-f92b246a51fc', 'User 2', 'Square 4', '2015-11-17 23:55:11.256'); 24 | INSERT INTO "user" (created_at, id, first_name, last_name, date_of_birth) VALUES('2019-09-20 22:42:04.837', '6ae95ea5-2132-49b3-9298-8883cea27ec7', 'User 3', 'Square 9', '1989-05-16 18:11:20.249'); 25 | INSERT INTO "user" (created_at, id, first_name, last_name, date_of_birth) VALUES('2019-09-20 22:42:04.837', '7459e394-3828-41b3-a907-31d3dcf1fd7d', 'User 4', 'Square 16', '2010-03-04 09:10:06.637'); 26 | INSERT INTO "user" (created_at, id, first_name, last_name, date_of_birth) VALUES('2019-09-20 22:42:04.837', '19ae7139-a080-4f14-9fca-a1f99511ee38', 'User 5', 'Square 25', '2004-08-11 17:13:42.745'); 27 | INSERT INTO "user" (created_at, id, first_name, last_name, date_of_birth) VALUES('2019-09-20 22:42:04.837', 'd0831283-ad93-48e7-90f6-cebc7ba84cb5', 'User 6', 'Square 36', '2007-11-07 22:54:25.929'); 28 | INSERT INTO "user" (created_at, id, first_name, last_name, date_of_birth) VALUES('2019-09-20 22:42:04.837', 'cf101e24-0698-4b71-a206-6b657d67d866', 'User 7', 'Square 49', '2014-11-21 15:44:24.091'); 29 | INSERT INTO "user" (created_at, id, first_name, last_name, date_of_birth) VALUES('2019-09-20 22:42:04.837', '3f045e1b-3ff7-429c-9ca1-e4e7585b48a6', 'User 8', 'Square 64', '${today}'); 30 | INSERT INTO "user" (created_at, id, first_name, last_name, date_of_birth) VALUES('2019-09-20 22:42:04.837', 'b9ca41e9-9ce9-4852-8b11-c6386cfb0e25', 'User 9', 'Square 81', '${tomorrow}'); 31 | ` 32 | ); 33 | } 34 | 35 | public async down(queryRunner: QueryRunner): Promise { 36 | await queryRunner.query(`DROP TABLE "user"`); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/users/src/models/User.ts: -------------------------------------------------------------------------------- 1 | import { User as UserMessage } from '@hash/protos/dist/users_pb'; 2 | import { 3 | Entity, 4 | Column, 5 | PrimaryGeneratedColumn, 6 | CreateDateColumn, 7 | FindConditions, 8 | getRepository, 9 | } from 'typeorm'; 10 | 11 | @Entity() 12 | export class User { 13 | @CreateDateColumn() 14 | public createdAt!: Date; 15 | 16 | @PrimaryGeneratedColumn('uuid') 17 | public id!: string; 18 | 19 | @Column('text') 20 | public firstName!: string; 21 | 22 | @Column('text') 23 | public lastName!: string; 24 | 25 | @Column('timestamp') 26 | public dateOfBirth!: Date; 27 | } 28 | 29 | export const create: { 30 | (base: Partial): Promise; 31 | (base: Partial[]): Promise; 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 33 | } = (base: any) => getRepository(User).save(base); 34 | export const find = (options?: FindConditions) => 35 | getRepository(User).find(options); 36 | export const findById = (id: string) => getRepository(User).findOne({ id }); 37 | export const findByIds = (ids: string[]) => getRepository(User).findByIds(ids); 38 | export const toMessage = (model: User) => { 39 | const proto = new UserMessage(); 40 | proto.setId(model.id); 41 | proto.setFirstName(model.firstName); 42 | proto.setLastName(model.lastName); 43 | proto.setDateOfBirth(model.dateOfBirth.getTime()); 44 | return proto; 45 | }; 46 | 47 | export type Create = typeof create; 48 | export type Find = typeof find; 49 | export type FindById = typeof findById; 50 | export type FindByIds = typeof findByIds; 51 | -------------------------------------------------------------------------------- /apps/users/tests/api.spec.ts: -------------------------------------------------------------------------------- 1 | import grpc from 'grpc'; 2 | import uuid from 'uuid/v4'; 3 | import R from 'ramda'; 4 | import { withContext as describe } from 'jest-with-context'; 5 | import { 6 | User, 7 | CreateUserRequest, 8 | ReadUserRequest, 9 | CreateUserResponse, 10 | ReadUserOptions, 11 | ListUsersRequest, 12 | ListUsersOptions, 13 | } from '@hash/protos/dist/users_pb'; 14 | import { UsersServiceClient } from '@hash/protos/dist/users_grpc_pb'; 15 | import { Status } from '@hash/protos/dist/google/status_pb'; 16 | import { Code } from '@hash/protos/dist/google/code_pb'; 17 | import { getRepository } from 'typeorm'; 18 | import * as Model from '../src/models/User'; 19 | import env from '../src/config'; 20 | import appPromise from '../src'; 21 | 22 | const SERVER_URL = env.listen; 23 | 24 | interface Context { 25 | service: UsersServiceClient; 26 | user: Model.User; 27 | users: Model.User[]; 28 | } 29 | 30 | const setup = async (state: Context): Promise => { 31 | const createUser = (i: number = Math.random()) => ({ 32 | createdAt: new Date(), 33 | id: uuid(), 34 | firstName: `User ${i}`, 35 | lastName: `Square ${i * i}`, 36 | dateOfBirth: new Date(Date.now() - Math.random() * 1e12), 37 | }); 38 | const createUsers = (amount: number) => { 39 | const users = R.times(createUser, amount); 40 | return Model.create(users); 41 | }; 42 | await appPromise; 43 | await getRepository(Model.User).clear(); 44 | 45 | const service = 46 | (state || {}).service || 47 | new UsersServiceClient(SERVER_URL, grpc.credentials.createInsecure()); 48 | const users = await createUsers(10); 49 | return { service, users, user: users[0] }; 50 | }; 51 | 52 | describe('createUser', ({ test, beforeAll }) => { 53 | beforeAll(setup); 54 | 55 | const request = ( 56 | service: UsersServiceClient, 57 | firstName?: string, 58 | lastName?: string, 59 | dateOfBirth?: number 60 | ) => { 61 | const request = new CreateUserRequest(); 62 | if (firstName) { 63 | request.setFirstName(firstName); 64 | } 65 | if (lastName) { 66 | request.setLastName(lastName); 67 | } 68 | if (dateOfBirth) { 69 | request.setDateOfBirth(dateOfBirth); 70 | } 71 | return service.createUser(request); 72 | }; 73 | test('user creation', async ({ service }) => { 74 | const response = await request(service, 'Edu', 'Dudu', 937519079881); 75 | const status = response.getStatus() as Status; 76 | const user = response.getUser() as User; 77 | 78 | expect(status).toBeDefined(); 79 | expect(user).toBeDefined(); 80 | 81 | expect(status.getCode()).toBe(Code.OK); 82 | expect(user.getFirstName()).toBe('Edu'); 83 | expect(user.getLastName()).toBe('Dudu'); 84 | expect(user.getDateOfBirth()).toBe(937519079881); 85 | }); 86 | test('input validation', async ({ service }) => { 87 | const assert = (response: CreateUserResponse) => { 88 | const status = response.getStatus() as Status; 89 | const user = response.getUser() as User; 90 | 91 | expect(status).toBeDefined(); 92 | expect(user).toBeUndefined(); 93 | 94 | expect(status.getCode()).toBe(Code.INVALID_ARGUMENT); 95 | }; 96 | 97 | const responses = await Promise.all([ 98 | request(service, undefined, 'xxx', 937519079881), 99 | request(service, 'yyy', undefined, 937519079881), 100 | request(service, 'aaa', 'bbb', undefined), 101 | ]); 102 | responses.forEach(assert); 103 | }); 104 | }); 105 | 106 | describe('readUser', ({ test, beforeAll }) => { 107 | beforeAll(setup); 108 | 109 | const request = ( 110 | service: UsersServiceClient, 111 | id?: string, 112 | maxAge?: number 113 | ) => { 114 | const request = new ReadUserRequest(); 115 | if (id) { 116 | request.setId(id); 117 | } 118 | if (maxAge) { 119 | const options = new ReadUserOptions(); 120 | options.setCacheAge(maxAge); 121 | request.setOptions(options); 122 | } 123 | return service.readUser(request); 124 | }; 125 | test('single read', async ({ service, user: model }) => { 126 | const response = await request(service, model.id); 127 | const status = response.getStatus() as Status; 128 | const user = response.getUser() as User; 129 | 130 | expect(status).toBeDefined(); 131 | expect(user).toBeDefined(); 132 | 133 | expect(status.getCode()).toBe(Code.OK); 134 | expect(user.getId()).toBe(model.id); 135 | expect(user.getFirstName()).toBe(model.firstName); 136 | expect(user.getLastName()).toBe(model.lastName); 137 | expect(user.getDateOfBirth()).toBe(model.dateOfBirth.getTime()); 138 | }); 139 | test('not found', async ({ service }) => { 140 | const response = await request(service, uuid()); 141 | const status = response.getStatus() as Status; 142 | 143 | expect(status).toBeDefined(); 144 | expect(response.getUser()).toBeUndefined(); 145 | 146 | expect(status.getCode()).toBe(Code.NOT_FOUND); 147 | }); 148 | test('invalid id', async ({ service }) => { 149 | const response = await request(service, 'invalid id'); 150 | const status = response.getStatus() as Status; 151 | 152 | expect(status).toBeDefined(); 153 | expect(response.getUser()).toBeUndefined(); 154 | 155 | expect(status.getCode()).toBe(Code.INVALID_ARGUMENT); 156 | }); 157 | }); 158 | 159 | describe('listUsers', ({ test, beforeAll }) => { 160 | beforeAll(setup); 161 | 162 | const request = ( 163 | service: UsersServiceClient, 164 | ids?: string[], 165 | maxAge?: number 166 | ) => { 167 | const request = new ListUsersRequest(); 168 | if (ids) { 169 | request.setIdList(ids); 170 | } 171 | if (maxAge) { 172 | const options = new ListUsersOptions(); 173 | options.setCacheAge(maxAge); 174 | request.setOptions(options); 175 | } 176 | return service.listUsers(request); 177 | }; 178 | test('list all users', async ({ service, users: models }) => { 179 | const response = await request(service); 180 | const status = response.getStatus() as Status; 181 | const users = response.getUsersList(); 182 | 183 | expect(status).toBeDefined(); 184 | expect(users.length).toBe(models.length); 185 | 186 | expect(status.getCode()).toBe(Code.OK); 187 | R.zip(users, models).forEach(([user, model]) => { 188 | expect(user.getId()).toBe(model.id); 189 | expect(user.getFirstName()).toBe(model.firstName); 190 | expect(user.getLastName()).toBe(model.lastName); 191 | expect(user.getDateOfBirth()).toBe(model.dateOfBirth.getTime()); 192 | }); 193 | }); 194 | test('by ids', async ({ service, users: models }) => { 195 | const selectedModels = models.slice(2, 5); 196 | const ids = selectedModels.map(model => model.id); 197 | const response = await request(service, ids); 198 | 199 | const status = response.getStatus() as Status; 200 | const users = response.getUsersList(); 201 | 202 | expect(status).toBeDefined(); 203 | expect(users.length).toBe(selectedModels.length); 204 | 205 | expect(status.getCode()).toBe(Code.OK); 206 | R.zip(users, selectedModels).forEach(([user, model]) => { 207 | expect(user.getId()).toBe(model.id); 208 | expect(user.getFirstName()).toBe(model.firstName); 209 | expect(user.getLastName()).toBe(model.lastName); 210 | expect(user.getDateOfBirth()).toBe(model.dateOfBirth.getTime()); 211 | }); 212 | }); 213 | }); 214 | 215 | afterAll(async () => { 216 | const { server, connection } = await appPromise; 217 | server.tryShutdown(() => {}); 218 | await connection.close(); 219 | }); 220 | -------------------------------------------------------------------------------- /apps/users/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "tests"], 3 | "compilerOptions": { 4 | /* Basic Options */ 5 | "incremental": true /* Enable incremental compilation */, 6 | "target": "ES2019" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 7 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 8 | // "lib": [], /* Specify library files to be included in the compilation. */ 9 | "allowJs": true /* Allow javascript files to be compiled. */, 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | "sourceMap": true /* Generates corresponding '.map' file. */, 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | "outDir": "./dist" /* Redirect output structure to the directory. */, 17 | "rootDir": "./" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 18 | // "composite": true, /* Enable project compilation */ 19 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 20 | // "removeComments": true, /* Do not emit comments to output. */ 21 | // "noEmit": true, /* Do not emit outputs. */ 22 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 23 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | 26 | /* Strict Type-Checking Options */ 27 | "strict": true /* Enable all strict type-checking options. */, 28 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 32 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 33 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | 36 | /* Additional Checks */ 37 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 38 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 39 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 40 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 41 | 42 | /* Module Resolution Options */ 43 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 44 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 45 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 46 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 47 | // "typeRoots": [], /* List of folders to include type definitions from. */ 48 | // "types": [], /* Type declaration files to be included in compilation. */ 49 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 50 | "resolveJsonModule": true, 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 63 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /apps/users/typeorm.sh: -------------------------------------------------------------------------------- 1 | ORMCONFIG=dist/src/config/ormconfig.js 2 | 3 | yarn build 4 | $(yarn bin typeorm) -f $ORMCONFIG $@ -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | db-users: 4 | image: postgres 5 | environment: 6 | POSTGRES_DB: users 7 | POSTGRES_PASSWORD: ai_tem_de_mudar_isso_aqui 8 | db-products: 9 | image: postgres 10 | environment: 11 | POSTGRES_DB: products 12 | POSTGRES_PASSWORD: ai_tem_de_mudar_isso_aqui 13 | api-users: 14 | depends_on: 15 | - "db-users" 16 | build: 17 | context: . 18 | dockerfile: apps/users/Dockerfile 19 | api-products: 20 | depends_on: 21 | - "db-products" 22 | build: 23 | context: . 24 | dockerfile: apps/products/Dockerfile 25 | ports: 26 | - "8080:8080" 27 | api-discounts: 28 | build: 29 | context: . 30 | dockerfile: apps/discounts/Dockerfile 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/EduardoRFS/hash-test 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/gin-gonic/gin v1.4.0 7 | github.com/go-pg/migrations/v7 v7.1.3 8 | github.com/go-pg/pg/v9 v9.0.0-beta.9 9 | github.com/golang/protobuf v1.3.2 10 | github.com/satori/go.uuid v1.2.0 11 | google.golang.org/genproto v0.0.0-20190916214212-f660b8655731 12 | google.golang.org/grpc v1.23.1 13 | ) 14 | 15 | replace google.golang.org/genproto/googleapis/rpc/code => ./packages/protos/dist/google.golang.org/genproto/googleapis/rpc/code 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 4 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 7 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 8 | github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3 h1:t8FVkw33L+wilf2QiWkw0UV77qRpcH/JHPKGpKa2E8g= 9 | github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= 10 | github.com/gin-gonic/gin v1.4.0 h1:3tMoCCfM7ppqsR0ptz/wi1impNpT7/9wQtMZ8lr1mCQ= 11 | github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= 12 | github.com/go-pg/migrations/v7 v7.1.3 h1:+ldouXR0U8JlhsmsCUbB4Y97dun22ETuMCvcR4c2UfU= 13 | github.com/go-pg/migrations/v7 v7.1.3/go.mod h1:GOuYQT7FTY5S71XU1vMN7j2GIveabfYyKhZGS7t1WfY= 14 | github.com/go-pg/pg/v9 v9.0.0-beta.7/go.mod h1:iVSTa1IJiCa0cN5cJJD5n0k3zYliVQC35Wq8nU82zIo= 15 | github.com/go-pg/pg/v9 v9.0.0-beta.9 h1:dmXXdqXoroVOksu6T0GJcvhBz9VmPCllFK4dw7Zo0lQ= 16 | github.com/go-pg/pg/v9 v9.0.0-beta.9/go.mod h1:CVIUglhmRRDSGR7/xuQA5treOua8keWWEO/F4B3vMFI= 17 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= 18 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 19 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 20 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 21 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 22 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= 23 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 24 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 25 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 26 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 27 | github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 28 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 29 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 30 | github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs= 31 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 32 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 33 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 34 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 35 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 36 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 37 | github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc= 38 | github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 39 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 40 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 41 | github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= 42 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 43 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 44 | github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w= 45 | github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 46 | github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo= 47 | github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 48 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 49 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 50 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= 51 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 52 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 53 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 54 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 55 | github.com/ugorji/go v1.1.4 h1:j4s+tAvLfL3bZyefP2SEWmhBzmuIlH/eqNuPdFPgngw= 56 | github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= 57 | github.com/vmihailenco/tagparser v0.1.0 h1:u6yzKTY6gW/KxL/K2NTEQUOSXZipyGiIRarGjJKmQzU= 58 | github.com/vmihailenco/tagparser v0.1.0/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= 59 | golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 60 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 61 | golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 62 | golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586 h1:7KByu05hhLed2MO29w7p1XfZvZ13m8mub3shuVftRs0= 63 | golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 64 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 65 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 66 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 67 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 68 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 69 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 70 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 71 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 72 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 73 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 74 | golang.org/x/net v0.0.0-20190420063019-afa5a82059c6/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 75 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c h1:uOCk1iQW6Vc18bnC13MfzScl+wdKBmM9Y9kU7Z83/lw= 76 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 77 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 78 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 79 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 80 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 81 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 82 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 83 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 84 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 85 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 86 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= 87 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 88 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 89 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 90 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 91 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 92 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 93 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 94 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 95 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 96 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 97 | google.golang.org/genproto v0.0.0-20190916214212-f660b8655731 h1:Phvl0+G5t5k/EUFUi0wPdUUeTL2HydMQUXHnunWgSb0= 98 | google.golang.org/genproto v0.0.0-20190916214212-f660b8655731/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= 99 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 100 | google.golang.org/grpc v1.23.1 h1:q4XQuHFC6I28BKZpo6IYyb3mNO+l7lSOxRuYTCiDfXk= 101 | google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 102 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 103 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 104 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 105 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 106 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 107 | gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= 108 | gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= 109 | gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ= 110 | gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= 111 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 112 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 113 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 114 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 115 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 116 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 117 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 118 | mellium.im/sasl v0.2.1 h1:nspKSRg7/SyO0cRGY71OkfHab8tf9kCts6a6oTDut0w= 119 | mellium.im/sasl v0.2.1/go.mod h1:ROaEDLQNuf9vjKqE1SrAfnsobm2YKXT1gnN1uDp1PjQ= 120 | -------------------------------------------------------------------------------- /hash-test.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "extensions": { 3 | "recommendations": [ 4 | "ms-azuretools.vscode-docker", 5 | "dbaeumer.vscode-eslint", 6 | "esbenp.prettier-vscode", 7 | "ms-vscode.go", 8 | "zxh404.vscode-proto3" 9 | ] 10 | }, 11 | "folders": [ 12 | { 13 | "name": "apps/discounts", 14 | "path": "apps/discounts" 15 | }, 16 | { 17 | "name": "apps/products", 18 | "path": "apps/products" 19 | }, 20 | { 21 | "name": "apps/users", 22 | "path": "apps/users" 23 | }, 24 | { 25 | "name": "packages/utils", 26 | "path": "packages/utils" 27 | }, 28 | { 29 | "name": "packages/protos", 30 | "path": "packages/protos" 31 | }, 32 | { 33 | "name": "hash-test", 34 | "path": "." 35 | } 36 | ], 37 | "settings": { 38 | "files.eol": "\n", 39 | "prettier.eslintIntegration": true, 40 | "[json]": { 41 | "editor.defaultFormatter": "esbenp.prettier-vscode" 42 | }, 43 | "eslint.autoFixOnSave": true, 44 | "eslint.validate": [ 45 | "javascript", 46 | "javascriptreact", 47 | { 48 | "language": "typescript", 49 | "autoFix": true 50 | }, 51 | { 52 | "language": "typescriptreact", 53 | "autoFix": true 54 | } 55 | ] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "containers": "docker inspect --format='{{.Name}} {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -q -a)", 5 | "databases": "yarn containers | grep db", 6 | "populate": "./POPULATE.sh", 7 | "deploy": "docker-compose up --build -d" 8 | }, 9 | "workspaces": [ 10 | "apps/*", 11 | "packages/*" 12 | ], 13 | "resolutions": { 14 | "@types/node": "https://github.com/EduardoRFS/types-node.git#6c454721f766383c872f87c24252be7391f90261" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/protos/BUILD.sh: -------------------------------------------------------------------------------- 1 | SRC="./src" 2 | DIST="./dist" 3 | PATH="$PATH:./bin" 4 | 5 | # Find protoc 6 | PROTOC="./node_modules/grpc-tools/bin/protoc" 7 | if test ! -f "$PROTOC"; then 8 | PROTOC="../../node_modules/grpc-tools/bin/protoc" 9 | fi 10 | 11 | mkdir -p ${DIST} 12 | 13 | # JavaScript + TypeScript 14 | grpc_tools_node_protoc \ 15 | --js_out=import_style=commonjs,binary:${DIST} \ 16 | --grpc_out=${DIST} \ 17 | --plugin=protoc-gen-grpc=`which grpc_tools_node_protoc_plugin` \ 18 | -I ${SRC} \ 19 | ${SRC}/*.proto ${SRC}/**/*.proto 20 | 21 | grpc_tools_node_protoc \ 22 | --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \ 23 | --ts_out=${DIST} \ 24 | -I ${SRC} \ 25 | ${SRC}/*.proto ${SRC}/**/*.proto 26 | 27 | for i in $(find ${SRC} -name \*.proto); do # 28 | $PROTOC \ 29 | --go_out=plugins=grpc,paths=import:${DIST} \ 30 | -I ${SRC} \ 31 | $i 32 | done 33 | -------------------------------------------------------------------------------- /packages/protos/README.md: -------------------------------------------------------------------------------- 1 | TODO: 2 | -------------------------------------------------------------------------------- /packages/protos/bin/protoc-gen-go: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EduardoRFS/hash-test/2ec944d677ee1ff5a6d8f01463e4898d2c4ff8d4/packages/protos/bin/protoc-gen-go -------------------------------------------------------------------------------- /packages/protos/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hash/protos", 3 | "version": "0.0.0", 4 | "main": "dist/index.js", 5 | "author": "EduardoRFS ", 6 | "license": "MIT", 7 | "private": true, 8 | "scripts": { 9 | "build": "./BUILD.sh" 10 | }, 11 | "devDependencies": { 12 | "@types/google-protobuf": "^3.7.1", 13 | "grpc": "https://github.com/EduardoRFS/grpc-native-core.git#20ba246427f179d5eaaf99bcca893fd7ccc29689", 14 | "grpc-tools": "^1.8.0", 15 | "grpc_tools_node_protoc_ts": "https://github.com/EduardoRFS/grpc_tools_node_protoc_ts.git#adcd407331a94ec0134db56db32c14dd73c6b161", 16 | "grpcc": "^1.1.3" 17 | }, 18 | "dependencies": { 19 | "google-protobuf": "^3.9.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/protos/src/discounts.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package protos; 4 | 5 | import "google/status.proto"; 6 | 7 | service DiscountsService { 8 | rpc FindDiscount(FindDiscountRequest) returns (FindDiscountResponse) {} 9 | rpc FindDiscounts(FindDiscountsRequest) returns (FindDiscountsResponse) {} 10 | } 11 | 12 | message FindDiscountOptions { 13 | uint32 cache_age = 1; 14 | } 15 | message FindDiscountRequest { 16 | FindDiscountOptions options = 1; 17 | string product_id = 2; 18 | string user_id = 3; 19 | } 20 | message FindDiscountResponse { 21 | google.rpc.Status status = 1; 22 | Discount discount = 2; 23 | } 24 | 25 | message DiscountRequest { 26 | string product_id = 1; 27 | string user_id = 2; 28 | } 29 | message FindDiscountsOptions { 30 | uint32 cache_age = 1; 31 | } 32 | message FindDiscountsRequest { 33 | FindDiscountsOptions options = 1; 34 | repeated DiscountRequest requests = 2; 35 | } 36 | message FindDiscountsResponse { 37 | google.rpc.Status status = 1; 38 | repeated Discount discount = 2; 39 | } 40 | 41 | message Discount { 42 | float pct = 1; 43 | int64 value_in_cents = 2; 44 | } 45 | -------------------------------------------------------------------------------- /packages/protos/src/google/any.proto: -------------------------------------------------------------------------------- 1 | // from https://github.com/google/protobuf/blob/master/src/google/protobuf/any.proto 2 | // 3 | // Protocol Buffers - Google's data interchange format 4 | // Copyright 2008 Google Inc. All rights reserved. 5 | // https://developers.google.com/protocol-buffers/ 6 | // 7 | // Redistribution and use in source and binary forms, with or without 8 | // modification, are permitted provided that the following conditions are 9 | // met: 10 | // 11 | // * Redistributions of source code must retain the above copyright 12 | // notice, this list of conditions and the following disclaimer. 13 | // * Redistributions in binary form must reproduce the above 14 | // copyright notice, this list of conditions and the following disclaimer 15 | // in the documentation and/or other materials provided with the 16 | // distribution. 17 | // * Neither the name of Google Inc. nor the names of its 18 | // contributors may be used to endorse or promote products derived from 19 | // this software without specific prior written permission. 20 | // 21 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 24 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 25 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 26 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 27 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 29 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 30 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 32 | 33 | syntax = "proto3"; 34 | 35 | package google.protobuf; 36 | 37 | option go_package = "github.com/golang/protobuf/ptypes/any"; 38 | option csharp_namespace = "Google.Protobuf.WellKnownTypes"; 39 | option java_package = "com.google.protobuf"; 40 | option java_outer_classname = "AnyProto"; 41 | option java_multiple_files = true; 42 | option objc_class_prefix = "GPB"; 43 | 44 | // `Any` contains an arbitrary serialized protocol buffer message along with a 45 | // URL that describes the type of the serialized message. 46 | // 47 | // Protobuf library provides support to pack/unpack Any values in the form 48 | // of utility functions or additional generated methods of the Any type. 49 | // 50 | // Example 1: Pack and unpack a message in C++. 51 | // 52 | // Foo foo = ...; 53 | // Any any; 54 | // any.PackFrom(foo); 55 | // ... 56 | // if (any.UnpackTo(&foo)) { 57 | // ... 58 | // } 59 | // 60 | // Example 2: Pack and unpack a message in Java. 61 | // 62 | // Foo foo = ...; 63 | // Any any = Any.pack(foo); 64 | // ... 65 | // if (any.is(Foo.class)) { 66 | // foo = any.unpack(Foo.class); 67 | // } 68 | // 69 | // Example 3: Pack and unpack a message in Python. 70 | // 71 | // foo = Foo(...) 72 | // any = Any() 73 | // any.Pack(foo) 74 | // ... 75 | // if any.Is(Foo.DESCRIPTOR): 76 | // any.Unpack(foo) 77 | // ... 78 | // 79 | // Example 4: Pack and unpack a message in Go 80 | // 81 | // foo := &pb.Foo{...} 82 | // any, err := ptypes.MarshalAny(foo) 83 | // ... 84 | // foo := &pb.Foo{} 85 | // if err := ptypes.UnmarshalAny(any, foo); err != nil { 86 | // ... 87 | // } 88 | // 89 | // The pack methods provided by protobuf library will by default use 90 | // 'type.googleapis.com/full.type.name' as the type URL and the unpack 91 | // methods only use the fully qualified type name after the last '/' 92 | // in the type URL, for example "foo.bar.com/x/y.z" will yield type 93 | // name "y.z". 94 | // 95 | // 96 | // JSON 97 | // ==== 98 | // The JSON representation of an `Any` value uses the regular 99 | // representation of the deserialized, embedded message, with an 100 | // additional field `@type` which contains the type URL. Example: 101 | // 102 | // package google.profile; 103 | // message Person { 104 | // string first_name = 1; 105 | // string last_name = 2; 106 | // } 107 | // 108 | // { 109 | // "@type": "type.googleapis.com/google.profile.Person", 110 | // "firstName": , 111 | // "lastName": 112 | // } 113 | // 114 | // If the embedded message type is well-known and has a custom JSON 115 | // representation, that representation will be embedded adding a field 116 | // `value` which holds the custom JSON in addition to the `@type` 117 | // field. Example (for message [google.protobuf.Duration][]): 118 | // 119 | // { 120 | // "@type": "type.googleapis.com/google.protobuf.Duration", 121 | // "value": "1.212s" 122 | // } 123 | // 124 | message Any { 125 | // A URL/resource name that uniquely identifies the type of the serialized 126 | // protocol buffer message. The last segment of the URL's path must represent 127 | // the fully qualified name of the type (as in 128 | // `path/google.protobuf.Duration`). The name should be in a canonical form 129 | // (e.g., leading "." is not accepted). 130 | // 131 | // In practice, teams usually precompile into the binary all types that they 132 | // expect it to use in the context of Any. However, for URLs which use the 133 | // scheme `http`, `https`, or no scheme, one can optionally set up a type 134 | // server that maps type URLs to message definitions as follows: 135 | // 136 | // * If no scheme is provided, `https` is assumed. 137 | // * An HTTP GET on the URL must yield a [google.protobuf.Type][] 138 | // value in binary format, or produce an error. 139 | // * Applications are allowed to cache lookup results based on the 140 | // URL, or have them precompiled into a binary to avoid any 141 | // lookup. Therefore, binary compatibility needs to be preserved 142 | // on changes to types. (Use versioned type names to manage 143 | // breaking changes.) 144 | // 145 | // Note: this functionality is not currently available in the official 146 | // protobuf release, and it is not used for type URLs beginning with 147 | // type.googleapis.com. 148 | // 149 | // Schemes other than `http`, `https` (or the empty scheme) might be 150 | // used with implementation specific semantics. 151 | // 152 | string type_url = 1; 153 | 154 | // Must be a valid serialized protocol buffer of the above specified type. 155 | bytes value = 2; 156 | } -------------------------------------------------------------------------------- /packages/protos/src/google/code.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2017 Google Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | package google.rpc; 18 | 19 | option go_package = "google.golang.org/genproto/googleapis/rpc/code;code"; 20 | option java_multiple_files = true; 21 | option java_outer_classname = "CodeProto"; 22 | option java_package = "com.google.rpc"; 23 | option objc_class_prefix = "RPC"; 24 | 25 | // The canonical error codes for Google APIs. 26 | // 27 | // 28 | // Sometimes multiple error codes may apply. Services should return 29 | // the most specific error code that applies. For example, prefer 30 | // `OUT_OF_RANGE` over `FAILED_PRECONDITION` if both codes apply. 31 | // Similarly prefer `NOT_FOUND` or `ALREADY_EXISTS` over `FAILED_PRECONDITION`. 32 | enum Code { 33 | // Not an error; returned on success 34 | // 35 | // HTTP Mapping: 200 OK 36 | OK = 0; 37 | 38 | // The operation was cancelled, typically by the caller. 39 | // 40 | // HTTP Mapping: 499 Client Closed Request 41 | CANCELLED = 1; 42 | 43 | // Unknown error. For example, this error may be returned when 44 | // a `Status` value received from another address space belongs to 45 | // an error space that is not known in this address space. Also 46 | // errors raised by APIs that do not return enough error information 47 | // may be converted to this error. 48 | // 49 | // HTTP Mapping: 500 Internal Server Error 50 | UNKNOWN = 2; 51 | 52 | // The client specified an invalid argument. Note that this differs 53 | // from `FAILED_PRECONDITION`. `INVALID_ARGUMENT` indicates arguments 54 | // that are problematic regardless of the state of the system 55 | // (e.g., a malformed file name). 56 | // 57 | // HTTP Mapping: 400 Bad Request 58 | INVALID_ARGUMENT = 3; 59 | 60 | // The deadline expired before the operation could complete. For operations 61 | // that change the state of the system, this error may be returned 62 | // even if the operation has completed successfully. For example, a 63 | // successful response from a server could have been delayed long 64 | // enough for the deadline to expire. 65 | // 66 | // HTTP Mapping: 504 Gateway Timeout 67 | DEADLINE_EXCEEDED = 4; 68 | 69 | // Some requested entity (e.g., file or directory) was not found. 70 | // 71 | // Note to server developers: if a request is denied for an entire class 72 | // of users, such as gradual feature rollout or undocumented whitelist, 73 | // `NOT_FOUND` may be used. If a request is denied for some users within 74 | // a class of users, such as user-based access control, `PERMISSION_DENIED` 75 | // must be used. 76 | // 77 | // HTTP Mapping: 404 Not Found 78 | NOT_FOUND = 5; 79 | 80 | // The entity that a client attempted to create (e.g., file or directory) 81 | // already exists. 82 | // 83 | // HTTP Mapping: 409 Conflict 84 | ALREADY_EXISTS = 6; 85 | 86 | // The caller does not have permission to execute the specified 87 | // operation. `PERMISSION_DENIED` must not be used for rejections 88 | // caused by exhausting some resource (use `RESOURCE_EXHAUSTED` 89 | // instead for those errors). `PERMISSION_DENIED` must not be 90 | // used if the caller can not be identified (use `UNAUTHENTICATED` 91 | // instead for those errors). This error code does not imply the 92 | // request is valid or the requested entity exists or satisfies 93 | // other pre-conditions. 94 | // 95 | // HTTP Mapping: 403 Forbidden 96 | PERMISSION_DENIED = 7; 97 | 98 | // The request does not have valid authentication credentials for the 99 | // operation. 100 | // 101 | // HTTP Mapping: 401 Unauthorized 102 | UNAUTHENTICATED = 16; 103 | 104 | // Some resource has been exhausted, perhaps a per-user quota, or 105 | // perhaps the entire file system is out of space. 106 | // 107 | // HTTP Mapping: 429 Too Many Requests 108 | RESOURCE_EXHAUSTED = 8; 109 | 110 | // The operation was rejected because the system is not in a state 111 | // required for the operation's execution. For example, the directory 112 | // to be deleted is non-empty, an rmdir operation is applied to 113 | // a non-directory, etc. 114 | // 115 | // Service implementors can use the following guidelines to decide 116 | // between `FAILED_PRECONDITION`, `ABORTED`, and `UNAVAILABLE`: 117 | // (a) Use `UNAVAILABLE` if the client can retry just the failing call. 118 | // (b) Use `ABORTED` if the client should retry at a higher level 119 | // (e.g., when a client-specified test-and-set fails, indicating the 120 | // client should restart a read-modify-write sequence). 121 | // (c) Use `FAILED_PRECONDITION` if the client should not retry until 122 | // the system state has been explicitly fixed. E.g., if an "rmdir" 123 | // fails because the directory is non-empty, `FAILED_PRECONDITION` 124 | // should be returned since the client should not retry unless 125 | // the files are deleted from the directory. 126 | // 127 | // HTTP Mapping: 400 Bad Request 128 | FAILED_PRECONDITION = 9; 129 | 130 | // The operation was aborted, typically due to a concurrency issue such as 131 | // a sequencer check failure or transaction abort. 132 | // 133 | // See the guidelines above for deciding between `FAILED_PRECONDITION`, 134 | // `ABORTED`, and `UNAVAILABLE`. 135 | // 136 | // HTTP Mapping: 409 Conflict 137 | ABORTED = 10; 138 | 139 | // The operation was attempted past the valid range. E.g., seeking or 140 | // reading past end-of-file. 141 | // 142 | // Unlike `INVALID_ARGUMENT`, this error indicates a problem that may 143 | // be fixed if the system state changes. For example, a 32-bit file 144 | // system will generate `INVALID_ARGUMENT` if asked to read at an 145 | // offset that is not in the range [0,2^32-1], but it will generate 146 | // `OUT_OF_RANGE` if asked to read from an offset past the current 147 | // file size. 148 | // 149 | // There is a fair bit of overlap between `FAILED_PRECONDITION` and 150 | // `OUT_OF_RANGE`. We recommend using `OUT_OF_RANGE` (the more specific 151 | // error) when it applies so that callers who are iterating through 152 | // a space can easily look for an `OUT_OF_RANGE` error to detect when 153 | // they are done. 154 | // 155 | // HTTP Mapping: 400 Bad Request 156 | OUT_OF_RANGE = 11; 157 | 158 | // The operation is not implemented or is not supported/enabled in this 159 | // service. 160 | // 161 | // HTTP Mapping: 501 Not Implemented 162 | UNIMPLEMENTED = 12; 163 | 164 | // Internal errors. This means that some invariants expected by the 165 | // underlying system have been broken. This error code is reserved 166 | // for serious errors. 167 | // 168 | // HTTP Mapping: 500 Internal Server Error 169 | INTERNAL = 13; 170 | 171 | // The service is currently unavailable. This is most likely a 172 | // transient condition, which can be corrected by retrying with 173 | // a backoff. 174 | // 175 | // See the guidelines above for deciding between `FAILED_PRECONDITION`, 176 | // `ABORTED`, and `UNAVAILABLE`. 177 | // 178 | // HTTP Mapping: 503 Service Unavailable 179 | UNAVAILABLE = 14; 180 | 181 | // Unrecoverable data loss or corruption. 182 | // 183 | // HTTP Mapping: 500 Internal Server Error 184 | DATA_LOSS = 15; 185 | } -------------------------------------------------------------------------------- /packages/protos/src/google/status.proto: -------------------------------------------------------------------------------- 1 | // from 2 | // https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto 3 | // Copyright 2017 Google Inc. 4 | // 5 | // Licensed under the Apache License, Version 2.0 (the "License"); 6 | // you may not use this file except in compliance with the License. 7 | // You may obtain a copy of the License at 8 | // 9 | // http://www.apache.org/licenses/LICENSE-2.0 10 | // 11 | // Unless required by applicable law or agreed to in writing, software 12 | // distributed under the License is distributed on an "AS IS" BASIS, 13 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | // See the License for the specific language governing permissions and 15 | // limitations under the License. 16 | 17 | syntax = "proto3"; 18 | 19 | package google.rpc; 20 | 21 | import "google/any.proto"; 22 | 23 | option go_package = "google.golang.org/genproto/googleapis/rpc/status;status"; 24 | option java_multiple_files = true; 25 | option java_outer_classname = "StatusProto"; 26 | option java_package = "com.google.rpc"; 27 | option objc_class_prefix = "RPC"; 28 | 29 | // The `Status` type defines a logical error model that is suitable for 30 | // different programming environments, including REST APIs and RPC APIs. It is 31 | // used by [gRPC](https://github.com/grpc). The error model is designed to be: 32 | // 33 | // - Simple to use and understand for most users 34 | // - Flexible enough to meet unexpected needs 35 | // 36 | // # Overview 37 | // 38 | // The `Status` message contains three pieces of data: error code, error 39 | // message, and error details. The error code should be an enum value of 40 | // [google.rpc.Code][google.rpc.Code], but it may accept additional error codes 41 | // if needed. The error message should be a developer-facing English message 42 | // that helps developers *understand* and *resolve* the error. If a localized 43 | // user-facing error message is needed, put the localized message in the error 44 | // details or localize it in the client. The optional error details may contain 45 | // arbitrary from 46 | // https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto 47 | // 48 | // information about the error. There is a predefined set of error detail types 49 | // in the package `google.rpc` that can be used for common error conditions. 50 | // 51 | // # Language mapping 52 | // 53 | // The `Status` message is the logical representation of the error model, but it 54 | // is not necessarily the actual wire format. When the `Status` message is 55 | // exposed in different client libraries and different wire protocols, it can be 56 | // mapped differently. For example, it will likely be mapped to some exceptions 57 | // in Java, but more likely mapped to some error codes in C. 58 | // 59 | // # Other uses 60 | // 61 | // The error model and the `Status` message can be used in a variety of 62 | // environments, either with or without APIs, to provide a 63 | // consistent developer experience across different environments. 64 | // 65 | // Example uses of this error model include: 66 | // 67 | // - Partial errors. If a service needs to return partial errors to the client, 68 | // it may embed the `Status` in the normal response to indicate the partial 69 | // errors. 70 | // 71 | // - Workflow errors. A typical workflow has multiple steps. Each step may 72 | // have a `Status` message for error reporting. 73 | // 74 | // - Batch operations. If a client uses batch request and batch response, the 75 | // `Status` message should be used directly inside batch response, one for 76 | // each error sub-response. 77 | // 78 | // - Asynchronous operations. If an API call embeds asynchronous operation 79 | // results in its response, the status of those operations should be 80 | // represented directly using the `Status` message. 81 | // 82 | // - Logging. If some API errors are stored in logs, the message `Status` could 83 | // be used directly after any stripping needed for security/privacy reasons. 84 | message Status { 85 | // The status code, which should be an enum value of 86 | // [google.rpc.Code][google.rpc.Code]. 87 | int32 code = 1; 88 | 89 | // A developer-facing error message, which should be in English. Any 90 | // user-facing error message should be localized and sent in the 91 | // [google.rpc.Status.details][google.rpc.Status.details] field, or localized 92 | // by the client. 93 | string message = 2; 94 | 95 | // A list of messages that carry the error details. There is a common set of 96 | // message types for APIs to use. 97 | repeated google.protobuf.Any details = 3; 98 | } -------------------------------------------------------------------------------- /packages/protos/src/products.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package protos; 4 | 5 | import "google/status.proto"; 6 | import "discounts.proto"; 7 | 8 | service ProductsService { 9 | rpc CreateProduct(CreateProductRequest) returns (CreateProductResponse); 10 | rpc ReadProduct(ReadProductRequest) returns (ReadProductResponse); 11 | rpc ListProducts(ListProductsRequest) returns (ListProductsResponse); 12 | } 13 | 14 | message CreateProductRequest { 15 | // all of them are required 16 | uint64 price_in_cents = 1; 17 | string title = 2; 18 | string description = 3; 19 | } 20 | message CreateProductResponse { 21 | google.rpc.Status status = 1; 22 | Product product = 2; 23 | } 24 | 25 | message ReadProductOptions { 26 | uint32 cache_age = 1; 27 | bool discount = 2; 28 | } 29 | message ReadProductRequest { 30 | ReadProductOptions options = 1; 31 | string id = 2; 32 | } 33 | message ReadProductResponse { 34 | google.rpc.Status status = 1; 35 | Product product = 2; 36 | } 37 | 38 | message ListProductsOptions { 39 | uint32 cache_age = 1; 40 | bool discount = 2; 41 | } 42 | message ListProductsRequest { 43 | ListProductsOptions options = 1; 44 | repeated string id = 2; 45 | } 46 | message ListProductsResponse { 47 | google.rpc.Status status = 1; 48 | repeated Product products = 2; 49 | } 50 | 51 | message Product { 52 | string id = 1; 53 | uint64 price_in_cents = 2; 54 | string title = 3; 55 | string description = 4; 56 | Discount discount = 5; 57 | } -------------------------------------------------------------------------------- /packages/protos/src/users.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package protos; 4 | 5 | import "google/status.proto"; 6 | 7 | service UsersService { 8 | rpc CreateUser(CreateUserRequest) returns (CreateUserResponse); 9 | rpc ReadUser(ReadUserRequest) returns (ReadUserResponse); 10 | rpc ListUsers(ListUsersRequest) returns (ListUsersResponse); 11 | } 12 | 13 | message CreateUserRequest { 14 | // all of them are required 15 | string first_name = 1; 16 | string last_name = 2; 17 | int64 date_of_birth = 3; 18 | } 19 | message CreateUserResponse { 20 | google.rpc.Status status = 1; 21 | User user = 2; 22 | } 23 | 24 | message ReadUserOptions { 25 | uint32 cache_age = 1; 26 | } 27 | message ReadUserRequest { 28 | ReadUserOptions options = 1; 29 | string id = 2; 30 | } 31 | message ReadUserResponse { 32 | google.rpc.Status status = 1; 33 | User user = 2; 34 | } 35 | 36 | message ListUsersOptions { 37 | uint32 cache_age = 1; 38 | } 39 | message ListUsersRequest { 40 | ListUsersOptions options = 1; 41 | repeated string id = 2; 42 | } 43 | message ListUsersResponse { 44 | google.rpc.Status status = 1; 45 | repeated User users = 2; 46 | } 47 | 48 | message User { 49 | string id = 1; 50 | string first_name = 2; 51 | string last_name = 3; 52 | int64 date_of_birth = 4; 53 | } -------------------------------------------------------------------------------- /packages/utils/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { project: './tsconfig.json' }, 3 | extends: [ 4 | 'airbnb-typescript/base', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 7 | 'plugin:prettier/recommended', 8 | 'prettier/@typescript-eslint', 9 | ], 10 | rules: { 11 | '@typescript-eslint/no-floating-promises': 'error', 12 | '@typescript-eslint/explicit-function-return-type': 0, 13 | }, 14 | overrides: [ 15 | { 16 | files: ['**/__tests__/**/*.ts', '*.spec.ts'], 17 | extends: ['plugin:jest/recommended'], 18 | rules: { 19 | 'jest/valid-describe': 0, 20 | 'no-shadow': 0, 21 | }, 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hash/utils", 3 | "version": "0.0.0", 4 | "main": "dist/src/index.js", 5 | "author": "EduardoRFS ", 6 | "license": "MIT", 7 | "private": true, 8 | "engines": { 9 | "node": ">=10.9" 10 | }, 11 | "scripts": { 12 | "test": "jest --coverage", 13 | "build": "tsc" 14 | }, 15 | "devDependencies": { 16 | "@types/jest": "^24.0.16", 17 | "@types/lodash.mapvalues": "^4.6.6", 18 | "@types/lru-cache": "^5.1.0", 19 | "@types/yup": "^0.26.23", 20 | "@typescript-eslint/eslint-plugin": "^2.0.0", 21 | "@typescript-eslint/parser": "^1.13.0", 22 | "eslint": "^6.1.0", 23 | "eslint-config-airbnb-base": "^13.2.0", 24 | "eslint-config-airbnb-typescript": "^4.0.1", 25 | "eslint-config-prettier": "^6.0.0", 26 | "eslint-plugin-import": "^2.18.2", 27 | "eslint-plugin-jest": "^22.14.1", 28 | "eslint-plugin-prettier": "^3.1.0", 29 | "jest": "^24.8.0", 30 | "prettier": "^1.18.2", 31 | "ts-jest": "^24.0.2", 32 | "typescript": "^3.5.3" 33 | }, 34 | "dependencies": { 35 | "@hash/protos": "0.0.0", 36 | "lodash.mapvalues": "^4.6.0", 37 | "lru-cache": "^5.1.1", 38 | "yup": "^0.27.0" 39 | }, 40 | "eslintIgnore": [ 41 | "src/__tests__/protos" 42 | ], 43 | "prettier": { 44 | "tabWidth": 2, 45 | "singleQuote": true, 46 | "trailingComma": "es5" 47 | }, 48 | "jest": { 49 | "roots": [ 50 | "/src", 51 | "/tests" 52 | ], 53 | "collectCoverageFrom": [ 54 | "!src/__tests__/protos/**" 55 | ], 56 | "testRegex": "(\\.|/)(test|spec)\\.[jt]sx?$", 57 | "preset": "ts-jest/presets/js-with-ts" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/cache.spec.ts: -------------------------------------------------------------------------------- 1 | import createCache from '../cache'; 2 | 3 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 4 | 5 | test('get & set', () => { 6 | const cache = createCache({ maxSize: 1 }); 7 | const value = Math.random(); 8 | 9 | cache.set('get', value); 10 | expect(cache.get('get')).toBe(value); 11 | }); 12 | test('maxSize', () => { 13 | const cache = createCache({ maxSize: 2 }); 14 | const valueA = Math.random(); 15 | const valueB = Math.random(); 16 | const valueC = Math.random(); 17 | 18 | cache.set('a', valueA); 19 | cache.set('b', valueB); 20 | 21 | expect(cache.get('a')).toBe(valueA); 22 | expect(cache.get('b')).toBe(valueB); 23 | 24 | cache.set('c', valueC); 25 | 26 | expect(cache.get('a')).toBe(valueA); 27 | expect(cache.get('b')).toBe(valueB); 28 | expect(cache.get('c')).toBe(valueC); 29 | }); 30 | test('maxAge', async () => { 31 | const cache = createCache({ maxSize: 1, maxAge: 200 }); 32 | const value = Math.random(); 33 | 34 | cache.set('maxAge', value); 35 | expect(cache.get('maxAge')).toBe(value); 36 | 37 | await sleep(100); 38 | expect(cache.get('maxAge')).toBe(value); 39 | expect(cache.get('maxAge', 50)).toBe(undefined); 40 | 41 | await sleep(150); 42 | expect(cache.get('maxAge')).toBe(undefined); 43 | }); 44 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/errorHandler.spec.ts: -------------------------------------------------------------------------------- 1 | import { Status } from '@hash/protos/dist/google/status_pb'; 2 | import { Code } from '@hash/protos/dist/google/code_pb'; 3 | import { ReadUserResponse } from '@hash/protos/dist/users_pb'; 4 | import errorHandler from '../grpc/errorHandler'; 5 | 6 | const assert = async (next: () => unknown, message?: string) => { 7 | const context = { res: new ReadUserResponse() }; 8 | const middleware = errorHandler(ReadUserResponse); 9 | 10 | await middleware(context, next); 11 | 12 | const status = context.res.getStatus() as Status; 13 | 14 | if (message) { 15 | expect(status).toBeDefined(); 16 | expect(status.getCode()).toBe(Code.INTERNAL); 17 | expect(status.getMessage()).toBe(message); 18 | } else { 19 | expect(status).toBeUndefined(); 20 | } 21 | }; 22 | 23 | test('no error', async () => { 24 | await assert(() => {}); 25 | }); 26 | test('no message', async () => { 27 | // eslint-disable-next-line prefer-promise-reject-errors 28 | const next = () => Promise.reject(123); 29 | await assert(next, 'Internal Server Error'); 30 | }); 31 | test('sync errors', async () => { 32 | const next = () => { 33 | throw new Error('potato'); 34 | }; 35 | await assert(next, 'potato'); 36 | }); 37 | test('async errors', async () => { 38 | const next = () => Promise.reject(new Error('tuturu')); 39 | await assert(next, 'tuturu'); 40 | }); 41 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/logger.spec.ts: -------------------------------------------------------------------------------- 1 | import { ReadUserResponse } from '@hash/protos/dist/users_pb'; 2 | import { Status } from '@hash/protos/dist/google/status_pb'; 3 | import createLogger from '../grpc/logger'; 4 | 5 | test('it works', async () => { 6 | const log = jest.fn(); 7 | const logger = createLogger({ log }); 8 | const context = { res: new ReadUserResponse() }; 9 | 10 | await logger(context, () => {}); 11 | 12 | expect(log).toHaveBeenCalled(); 13 | }); 14 | test('with status', async () => { 15 | const log = jest.fn(); 16 | const logger = createLogger({ log }); 17 | const context = { res: new ReadUserResponse() }; 18 | context.res.setStatus(new Status()); 19 | 20 | await logger(context, () => {}); 21 | 22 | expect(log).toHaveBeenCalled(); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/memoize.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ReadUserRequest, 3 | ReadUserOptions, 4 | ReadUserResponse, 5 | } from '@hash/protos/dist/users_pb'; 6 | import memoize from '../grpc/memoize'; 7 | 8 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 9 | 10 | const createContext = (id: string, cacheAge: number) => { 11 | const options = new ReadUserOptions(); 12 | const request = new ReadUserRequest(); 13 | const response = new ReadUserResponse(); 14 | options.setCacheAge(cacheAge); 15 | request.setId(id); 16 | request.setOptions(options); 17 | 18 | return { req: request, res: response }; 19 | }; 20 | test('avoid calls', async () => { 21 | const cache = memoize.cache({ maxSize: 10 }); 22 | const memoized = memoize(ctx => { 23 | ctx.res = new ReadUserResponse(); 24 | }, cache); 25 | 26 | const contextA = createContext('avoid calls', 20); 27 | const contextB = createContext('avoid calls', 20); 28 | 29 | await memoized(contextA, () => {}); 30 | await memoized(contextB, () => {}); 31 | 32 | expect(contextA.res).toBe(contextB.res); 33 | }); 34 | describe('cache invalidation', () => { 35 | test('by request', async () => { 36 | const cache = memoize.cache({ maxSize: 10 }); 37 | const memoized = memoize(ctx => { 38 | ctx.res = new ReadUserResponse(); 39 | }, cache); 40 | 41 | const contextA = createContext('avoid calls A', 20); 42 | const contextB = createContext('avoid calls B', 20); 43 | 44 | await memoized(contextA, () => {}); 45 | await memoized(contextB, () => {}); 46 | 47 | expect(contextA.res).not.toBe(contextB.res); 48 | }); 49 | test('age', async () => { 50 | const cache = memoize.cache({ maxSize: 10 }); 51 | const memoized = memoize(ctx => { 52 | ctx.res = new ReadUserResponse(); 53 | }, cache); 54 | 55 | const contextA = createContext('avoid calls', 20); 56 | const contextB = createContext('avoid calls', 20); 57 | 58 | await memoized(contextA, () => {}); 59 | await memoized(contextB, () => {}); 60 | expect(contextA.res).toBe(contextB.res); 61 | 62 | await sleep(10); 63 | 64 | await memoized(contextA, () => {}); 65 | expect(contextA.res).toBe(contextB.res); 66 | 67 | await sleep(11); 68 | 69 | await memoized(contextA, () => {}); 70 | expect(contextA.res).not.toBe(contextB.res); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/respond.spec.ts: -------------------------------------------------------------------------------- 1 | import { Status } from '@hash/protos/dist/google/status_pb'; 2 | import { ReadUserResponse, User } from '@hash/protos/dist/users_pb'; 3 | import createRespond, { namingMap } from '../grpc/respond'; 4 | 5 | type Keys = keyof typeof namingMap; 6 | 7 | const respond = createRespond(ReadUserResponse); 8 | 9 | test('return the right code', () => { 10 | const keys = Object.keys(namingMap) as Keys[]; 11 | expect.assertions(keys.length); 12 | 13 | keys.forEach(key => { 14 | const response = respond[key](); 15 | const status = response.getStatus() as Status; 16 | expect(status.getCode()).toBe(namingMap[key]); 17 | }); 18 | }); 19 | test('callback', () => { 20 | const user = new User(); 21 | const response = respond.ok(res => res.setUser(user)); 22 | expect(response.getUser()).toBe(user); 23 | }); 24 | test('status options', () => { 25 | const response = respond.invalidArgument({ 26 | message: 'something really bad happened', 27 | details: ['detail A', 'detail B'], 28 | }); 29 | const status = response.getStatus() as Status; 30 | const details = status.getDetailsList().map(value => value.getValue()); 31 | 32 | expect(status.getMessage()).toBe('something really bad happened'); 33 | expect(details).toEqual(['detail A', 'detail B']); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/validation.spec.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import { 3 | ReadUserResponse, 4 | ReadUserRequest, 5 | ReadUserOptions, 6 | } from '@hash/protos/dist/users_pb'; 7 | import validation from '../grpc/validation'; 8 | 9 | const createContext = (id: string, cacheAge: number) => { 10 | const options = new ReadUserOptions(); 11 | const request = new ReadUserRequest(); 12 | const response = new ReadUserResponse(); 13 | options.setCacheAge(cacheAge); 14 | request.setId(id); 15 | request.setOptions(options); 16 | 17 | return { req: request, res: response }; 18 | }; 19 | test('valid input', async () => { 20 | const context = createContext('123', 5); 21 | const schema = yup.object({ id: yup.string() }); 22 | const middleware = validation(ReadUserResponse, schema); 23 | const next = jest.fn(); 24 | 25 | await middleware(context, next); 26 | 27 | expect(next).toHaveBeenCalled(); 28 | }); 29 | test('invalid input', async () => { 30 | const context = createContext('abc', 6); 31 | const schema = yup.object({ id: yup.number() }); 32 | const middleware = validation(ReadUserResponse, schema); 33 | 34 | const next = jest.fn(); 35 | await middleware(context, next); 36 | expect(next).not.toHaveBeenCalled(); 37 | }); 38 | -------------------------------------------------------------------------------- /packages/utils/src/cache.ts: -------------------------------------------------------------------------------- 1 | import LRU from 'lru-cache'; 2 | 3 | // wrapper for providing get based on maxAge 4 | 5 | export interface CacheOptions { 6 | maxAge?: number; 7 | maxSize: number; 8 | } 9 | export interface Cache { 10 | get: (key: K, maxAge?: number) => V | undefined; 11 | set: (key: K, value: V) => void; 12 | } 13 | const createCache = (options: CacheOptions): Cache => { 14 | const cache = new LRU(options); 15 | return { 16 | get(key, maxAge = options.maxAge) { 17 | const data = cache.get(key); 18 | return data && 19 | (typeof maxAge !== 'number' || maxAge > Date.now() - data.time) 20 | ? data.value 21 | : undefined; 22 | }, 23 | set(key, value) { 24 | const time = Date.now(); 25 | cache.set(key, { time, value }); 26 | }, 27 | }; 28 | }; 29 | export default createCache; 30 | -------------------------------------------------------------------------------- /packages/utils/src/grpc/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import { Response, Class } from './interfaces'; 2 | import createRespond from './respond'; 3 | 4 | export type ErrorHandler = ( 5 | Factory: Class 6 | ) => (ctx: { res: T }, next: () => unknown) => Promise; 7 | 8 | const createErrorHandler: ErrorHandler = Factory => { 9 | const { internal } = createRespond(Factory); 10 | 11 | return (ctx, next) => 12 | Promise.resolve() 13 | .then(next) 14 | .catch(error => { 15 | ctx.res = internal({ 16 | message: (error && error.message) || 'Internal Server Error', 17 | }); 18 | }); 19 | }; 20 | export default createErrorHandler; 21 | -------------------------------------------------------------------------------- /packages/utils/src/grpc/index.ts: -------------------------------------------------------------------------------- 1 | export { default as errorHandler } from './errorHandler'; 2 | export { default as logger } from './logger'; 3 | export { default as memoize } from './memoize'; 4 | export { default as createRespond } from './respond'; 5 | export { default as createValidation } from './validation'; 6 | -------------------------------------------------------------------------------- /packages/utils/src/grpc/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { Status } from '@hash/protos/dist/google/status_pb'; 2 | 3 | export interface Request { 4 | toObject(): object; 5 | } 6 | export interface Response { 7 | getStatus(): Status | undefined; 8 | setStatus(status?: Status): void; 9 | } 10 | export interface Class { 11 | new (): T; 12 | } 13 | -------------------------------------------------------------------------------- /packages/utils/src/grpc/logger.ts: -------------------------------------------------------------------------------- 1 | import { Response } from './interfaces'; 2 | 3 | export type Logger = (logger: { 4 | log: (message: string) => void; 5 | }) => (ctx: { res: Response }, next: () => unknown) => Promise; 6 | 7 | const createLogger: Logger = logger => async (ctx, next) => { 8 | const initialTime = Date.now(); 9 | await next(); 10 | 11 | const time = Date.now() - initialTime; 12 | const status = ctx.res && ctx.res.getStatus(); 13 | const code = status ? status.getCode() : -1; 14 | 15 | logger.log(`Status Code: ${code} - Latency: ${time}ms`); 16 | }; 17 | export default createLogger; 18 | -------------------------------------------------------------------------------- /packages/utils/src/grpc/memoize.ts: -------------------------------------------------------------------------------- 1 | import createCache, { CacheOptions, Cache as LRUCache } from '../cache'; 2 | import { Request, Response } from './interfaces'; 3 | 4 | // why? Typescript doesn't support declaration merging with arrow functions 5 | // TODO: improve typing to keep it intact, support next, support custom compare 6 | function memoize< 7 | Request extends memoize.Cacheable, 8 | Context extends { req: Request; res: unknown } 9 | >(middleware: memoize.Middleware, cache: memoize.Cache) { 10 | return async (ctx: Context, next: () => unknown) => { 11 | const key = ctx.req.serializeBinary().toString(); 12 | const request = ctx.req.toObject(); 13 | const age = request.options && request.options.cacheAge; 14 | const value = cache.get(key, age); 15 | 16 | if (value) { 17 | ctx.res = value.res; 18 | return; 19 | // return value.data; 20 | } 21 | 22 | const data = await middleware(ctx, () => { 23 | throw new Error("memoize doesn't implement next support "); 24 | }); 25 | cache.set(key, { res: ctx.res, data }); 26 | // return data; 27 | }; 28 | } 29 | // eslint-disable-next-line 30 | namespace memoize { 31 | export type Cache = LRUCache; 32 | export interface Cacheable extends Request { 33 | toObject(): { 34 | options?: { 35 | cacheAge: number; 36 | }; 37 | }; 38 | serializeBinary(): Uint8Array; 39 | } 40 | export type Middleware = ( 41 | ctx: T, 42 | next: () => Promise 43 | ) => void | Promise; 44 | export const cache = (options: CacheOptions): Cache => createCache(options); 45 | } 46 | 47 | export default memoize; 48 | -------------------------------------------------------------------------------- /packages/utils/src/grpc/respond.ts: -------------------------------------------------------------------------------- 1 | import { Code } from '@hash/protos/dist/google/code_pb'; 2 | import { Status } from '@hash/protos/dist/google/status_pb'; 3 | import { Any } from '@hash/protos/dist/google/any_pb'; 4 | import mapValues from 'lodash.mapvalues'; 5 | import { Class, Response } from './interfaces'; 6 | 7 | type StatusOptions = { 8 | message: string; 9 | details?: string[]; 10 | }; 11 | type Helper = ( 12 | callback?: ((response: T, status: Status) => void) | StatusOptions 13 | ) => T; 14 | 15 | const stringToAny = (string: string): Any => { 16 | const any = new Any(); 17 | any.setValue(string); 18 | return any; 19 | }; 20 | export const namingMap = { 21 | ok: Code.OK, 22 | cancelled: Code.CANCELLED, 23 | unknown: Code.UNKNOWN, 24 | invalidArgument: Code.INVALID_ARGUMENT, 25 | deadlineExceeded: Code.DEADLINE_EXCEEDED, 26 | notFound: Code.NOT_FOUND, 27 | alreadyExists: Code.ALREADY_EXISTS, 28 | permissionDenied: Code.PERMISSION_DENIED, 29 | unauthenticated: Code.UNAUTHENTICATED, 30 | resourceExhausted: Code.RESOURCE_EXHAUSTED, 31 | failedPrecondition: Code.FAILED_PRECONDITION, 32 | aborted: Code.ABORTED, 33 | outOfRange: Code.OUT_OF_RANGE, 34 | unimplemented: Code.UNIMPLEMENTED, 35 | internal: Code.INTERNAL, 36 | unavailable: Code.UNAVAILABLE, 37 | dataLoss: Code.DATA_LOSS, 38 | } as const; 39 | const createRespond = (Factory: Class) => { 40 | const createStatusOptions = (code: Code): Helper => callback => { 41 | const response = new Factory(); 42 | const status = new Status(); 43 | status.setCode(code); 44 | response.setStatus(status); 45 | 46 | if (typeof callback === 'object') { 47 | const options = callback; 48 | status.setMessage(options.message); 49 | status.setDetailsList((options.details || []).map(stringToAny)); 50 | } 51 | if (typeof callback === 'function') { 52 | callback(response, status); 53 | } 54 | return response; 55 | }; 56 | 57 | const helpers = mapValues(namingMap, createStatusOptions); 58 | 59 | return Object.assign(createStatusOptions, helpers); 60 | }; 61 | export default createRespond; 62 | -------------------------------------------------------------------------------- /packages/utils/src/grpc/validation.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import { Request, Response, Class } from './interfaces'; 3 | import createRespond from './respond'; 4 | 5 | export type Validation = ( 6 | Factory: Class, 7 | schema: yup.Schema 8 | ) => ( 9 | ctx: { 10 | req: Request; 11 | res: T; 12 | }, 13 | next: () => unknown 14 | ) => Promise; 15 | 16 | const createValidation: Validation = (Factory, schema) => { 17 | const { invalidArgument } = createRespond(Factory); 18 | 19 | return async (ctx, next) => { 20 | try { 21 | await schema.validate(ctx.req.toObject()); 22 | await next(); 23 | } catch (error) { 24 | if (!(error instanceof yup.ValidationError)) { 25 | throw error; 26 | } 27 | ctx.res = invalidArgument({ 28 | message: error.message, 29 | details: error.errors.map(detail => 30 | Buffer.from(detail).toString('base64') 31 | ), 32 | }); 33 | } 34 | }; 35 | }; 36 | export default createValidation; 37 | -------------------------------------------------------------------------------- /packages/utils/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './grpc'; 2 | export { default as createCache, Cache, CacheOptions } from './cache'; 3 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | /* Basic Options */ 5 | "incremental": true /* Enable incremental compilation */, 6 | "target": "ES2019" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 7 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 8 | // "lib": [], /* Specify library files to be included in the compilation. */ 9 | // "allowJs": true /* Allow javascript files to be compiled. */, 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | "declaration": true /* Generates corresponding '.d.ts' file. */, 13 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 14 | "sourceMap": true /* Generates corresponding '.map' file. */, 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | "outDir": "./dist" /* Redirect output structure to the directory. */, 17 | "rootDir": "./" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 18 | // "composite": true, /* Enable project compilation */ 19 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 20 | // "removeComments": true, /* Do not emit comments to output. */ 21 | // "noEmit": true, /* Do not emit outputs. */ 22 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 23 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | 26 | /* Strict Type-Checking Options */ 27 | "strict": true /* Enable all strict type-checking options. */, 28 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 32 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 33 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | 36 | /* Additional Checks */ 37 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 38 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 39 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 40 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 41 | 42 | /* Module Resolution Options */ 43 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 44 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 45 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 46 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 47 | // "typeRoots": [], /* List of folders to include type definitions from. */ 48 | // "types": [], /* Type declaration files to be included in compilation. */ 49 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 50 | "resolveJsonModule": true, 51 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 63 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ 64 | } 65 | } 66 | --------------------------------------------------------------------------------