serverest 3.2.1 → 3.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,6 +124,48 @@ Em ambos os comandos de subida de ambiente local será utilizado a última vers
124
124
 
125
125
  Você pode encontrar as versões disponíveis na [lista de tags no Docker Hub](https://hub.docker.com/r/paulogoncalvesbh/serverest/tags) e na [lista de versões do NPM](https://www.npmjs.com/package/serverest).
126
126
 
127
+ ## Etapas da requisição
128
+
129
+ Toda resposta das rotas `/login`, `/usuarios`, `/produtos` e `/carrinhos` informa por quais etapas do servidor a requisição passou, quanto tempo cada uma levou e qual delas interrompeu a requisição. Assim é possível entender, sem ler o código, por que a resposta foi 401 e não 400 quando o token e o body estão errados ao mesmo tempo.
130
+
131
+ | Cabeçalho | Conteúdo |
132
+ |---|---|
133
+ | `X-ServeRest-Stages` | Todas as etapas da rota, em ordem de execução, com status, duração e detalhes |
134
+
135
+ O cabeçalho é listado em `Access-Control-Expose-Headers`. Requisições `OPTIONS` (preflight de CORS) e as demais rotas (`/`, `/version`, `/swagger.json`) não possuem esse cabeçalho.
136
+
137
+ Exemplo de 401 sem token em `POST /carrinhos`:
138
+
139
+ ```
140
+ X-ServeRest-Stages: body;st=ok;dur=0.21, rate_limit;st=skipped, auth;st=failed;dur=0.05;reason=token_missing, schema;st=not_run, rules;st=not_run, write;st=not_run
141
+ ```
142
+
143
+ Cada entrada é um nome seguido de parâmetros `;chave=valor`, separadas por vírgula. Nome e valores usam apenas letras, números, `_` e `.`.
144
+
145
+ | Parâmetro | Valores | Presente quando |
146
+ |---|---|---|
147
+ | `st` | `ok`, `failed`, `skipped`, `not_run` | Sempre |
148
+ | `dur` | Milissegundos com duas casas decimais | A etapa executou (`ok` ou `failed`) |
149
+ | `reason` | `token_missing`, `bad_format`, `token_expired`, `token_invalid`, `user_not_found`, `not_admin` | A etapa `auth` falhou |
150
+ | `item` | Índice, a partir de zero, do item em `produtos` | Uma regra falhou em um item específico |
151
+
152
+ | Status | Significado |
153
+ |---|---|
154
+ | `ok` | Executou e deixou a requisição continuar |
155
+ | `failed` | Executou e interrompeu a requisição. No máximo uma etapa e uma regra por resposta |
156
+ | `skipped` | Não se aplica neste ambiente (rate limit ao executar localmente) |
157
+ | `not_run` | Não executou porque uma etapa anterior interrompeu a requisição |
158
+
159
+ As etapas são `body`, `rate_limit`, `auth`, `schema`, `read` (rotas GET), `rules` (regras de negócio e, enquanto a rota não for detalhada, também a gravação) e `write` (gravação, hoje apenas em `POST /carrinhos`). Entradas no formato `<etapa>.<regra>`, como `rules.enough_stock`, são regras da etapa citada antes do ponto: vêm logo depois dela e nunca possuem `dur`.
160
+
161
+ Casos que merecem atenção:
162
+
163
+ - Status 400 ou maior sem nenhuma etapa `failed` significa que a resposta veio de fora das etapas: rota inexistente (405) ou URL malformada (400).
164
+ - Em um timeout (408) a etapa que estava executando quando o tempo acabou é a marcada como `failed`.
165
+ - Em `POST /carrinhos` as regras `rules.product_exists` e `rules.enough_stock` são avaliadas item a item, na ordem do array, e param no primeiro item que falha. Portanto `rules.product_exists;st=ok` ao lado de `rules.enough_stock;st=failed;item=1` significa "nenhum produto inexistente até o item que falhou", e não "todos os produtos existem".
166
+
167
+ Evolução: versões futuras apenas adicionam entradas, como uma etapa `write` depois de `rules` ou entradas `rules.<regra>` em mais rotas. A gramática, os nomes dos parâmetros, os valores de status e os nomes das etapas acima são estáveis; renomear ou remover algum deles é uma quebra de compatibilidade.
168
+
127
169
  ## Teste de carga
128
170
 
129
171
  ### IMPORTANTE
package/docs/swagger.json CHANGED
@@ -52,6 +52,11 @@
52
52
  "responses": {
53
53
  "200": {
54
54
  "description": "paths.login.post.responses.200.description",
55
+ "headers": {
56
+ "X-ServeRest-Stages": {
57
+ "$ref": "#/components/headers/XServeRestStages"
58
+ }
59
+ },
55
60
  "content": {
56
61
  "application/json": {
57
62
  "schema": {
@@ -62,6 +67,11 @@
62
67
  },
63
68
  "401": {
64
69
  "description": "paths.login.post.responses.401.description",
70
+ "headers": {
71
+ "X-ServeRest-Stages": {
72
+ "$ref": "#/components/headers/XServeRestStages"
73
+ }
74
+ },
65
75
  "content": {
66
76
  "application/json": {
67
77
  "schema": {
@@ -99,6 +109,11 @@
99
109
  "responses": {
100
110
  "200": {
101
111
  "description": "paths.usuarios.get.responses.200.description",
112
+ "headers": {
113
+ "X-ServeRest-Stages": {
114
+ "$ref": "#/components/headers/XServeRestStages"
115
+ }
116
+ },
102
117
  "content": {
103
118
  "application/json": {
104
119
  "schema": {
@@ -121,6 +136,11 @@
121
136
  "responses": {
122
137
  "201": {
123
138
  "description": "paths.usuarios.post.responses.201.description",
139
+ "headers": {
140
+ "X-ServeRest-Stages": {
141
+ "$ref": "#/components/headers/XServeRestStages"
142
+ }
143
+ },
124
144
  "content": {
125
145
  "application/json": {
126
146
  "schema": {
@@ -156,6 +176,11 @@
156
176
  "responses": {
157
177
  "200": {
158
178
  "description": "paths.usuariosById.get.responses.200.description",
179
+ "headers": {
180
+ "X-ServeRest-Stages": {
181
+ "$ref": "#/components/headers/XServeRestStages"
182
+ }
183
+ },
159
184
  "content": {
160
185
  "application/json": {
161
186
  "schema": {
@@ -166,6 +191,11 @@
166
191
  },
167
192
  "400": {
168
193
  "description": "paths.usuariosById.get.responses.400.description",
194
+ "headers": {
195
+ "X-ServeRest-Stages": {
196
+ "$ref": "#/components/headers/XServeRestStages"
197
+ }
198
+ },
169
199
  "content": {
170
200
  "application/json": {
171
201
  "schema": {
@@ -203,6 +233,11 @@
203
233
  "responses": {
204
234
  "200": {
205
235
  "description": "paths.usuariosById.delete.responses.200.description",
236
+ "headers": {
237
+ "X-ServeRest-Stages": {
238
+ "$ref": "#/components/headers/XServeRestStages"
239
+ }
240
+ },
206
241
  "content": {
207
242
  "application/json": {
208
243
  "schema": {
@@ -219,6 +254,11 @@
219
254
  },
220
255
  "400": {
221
256
  "description": "paths.usuariosById.delete.responses.400.description",
257
+ "headers": {
258
+ "X-ServeRest-Stages": {
259
+ "$ref": "#/components/headers/XServeRestStages"
260
+ }
261
+ },
222
262
  "content": {
223
263
  "application/json": {
224
264
  "schema": {
@@ -253,6 +293,11 @@
253
293
  "responses": {
254
294
  "200": {
255
295
  "description": "paths.usuariosById.put.responses.200.description",
296
+ "headers": {
297
+ "X-ServeRest-Stages": {
298
+ "$ref": "#/components/headers/XServeRestStages"
299
+ }
300
+ },
256
301
  "content": {
257
302
  "application/json": {
258
303
  "schema": {
@@ -263,6 +308,11 @@
263
308
  },
264
309
  "201": {
265
310
  "description": "paths.usuariosById.put.responses.201.description",
311
+ "headers": {
312
+ "X-ServeRest-Stages": {
313
+ "$ref": "#/components/headers/XServeRestStages"
314
+ }
315
+ },
266
316
  "content": {
267
317
  "application/json": {
268
318
  "schema": {
@@ -303,6 +353,11 @@
303
353
  "responses": {
304
354
  "200": {
305
355
  "description": "paths.produtos.get.responses.200.description",
356
+ "headers": {
357
+ "X-ServeRest-Stages": {
358
+ "$ref": "#/components/headers/XServeRestStages"
359
+ }
360
+ },
306
361
  "content": {
307
362
  "application/json": {
308
363
  "schema": {
@@ -330,6 +385,11 @@
330
385
  "responses": {
331
386
  "201": {
332
387
  "description": "paths.produtos.post.responses.201.description",
388
+ "headers": {
389
+ "X-ServeRest-Stages": {
390
+ "$ref": "#/components/headers/XServeRestStages"
391
+ }
392
+ },
333
393
  "content": {
334
394
  "application/json": {
335
395
  "schema": {
@@ -371,6 +431,11 @@
371
431
  "responses": {
372
432
  "200": {
373
433
  "description": "paths.produtosById.get.responses.200.description",
434
+ "headers": {
435
+ "X-ServeRest-Stages": {
436
+ "$ref": "#/components/headers/XServeRestStages"
437
+ }
438
+ },
374
439
  "content": {
375
440
  "application/json": {
376
441
  "schema": {
@@ -381,6 +446,11 @@
381
446
  },
382
447
  "400": {
383
448
  "description": "paths.produtosById.get.responses.400.description",
449
+ "headers": {
450
+ "X-ServeRest-Stages": {
451
+ "$ref": "#/components/headers/XServeRestStages"
452
+ }
453
+ },
384
454
  "content": {
385
455
  "application/json": {
386
456
  "schema": {
@@ -423,6 +493,11 @@
423
493
  "responses": {
424
494
  "200": {
425
495
  "description": "paths.produtosById.delete.responses.200.description",
496
+ "headers": {
497
+ "X-ServeRest-Stages": {
498
+ "$ref": "#/components/headers/XServeRestStages"
499
+ }
500
+ },
426
501
  "content": {
427
502
  "application/json": {
428
503
  "schema": {
@@ -439,6 +514,11 @@
439
514
  },
440
515
  "400": {
441
516
  "description": "paths.produtosById.delete.responses.400.description",
517
+ "headers": {
518
+ "X-ServeRest-Stages": {
519
+ "$ref": "#/components/headers/XServeRestStages"
520
+ }
521
+ },
442
522
  "content": {
443
523
  "application/json": {
444
524
  "schema": {
@@ -484,6 +564,11 @@
484
564
  "responses": {
485
565
  "200": {
486
566
  "description": "paths.produtosById.put.responses.200.description",
567
+ "headers": {
568
+ "X-ServeRest-Stages": {
569
+ "$ref": "#/components/headers/XServeRestStages"
570
+ }
571
+ },
487
572
  "content": {
488
573
  "application/json": {
489
574
  "schema": {
@@ -494,6 +579,11 @@
494
579
  },
495
580
  "201": {
496
581
  "description": "paths.produtosById.put.responses.201.description",
582
+ "headers": {
583
+ "X-ServeRest-Stages": {
584
+ "$ref": "#/components/headers/XServeRestStages"
585
+ }
586
+ },
497
587
  "content": {
498
588
  "application/json": {
499
589
  "schema": {
@@ -538,6 +628,11 @@
538
628
  "responses": {
539
629
  "200": {
540
630
  "description": "paths.carrinhos.get.responses.200.description",
631
+ "headers": {
632
+ "X-ServeRest-Stages": {
633
+ "$ref": "#/components/headers/XServeRestStages"
634
+ }
635
+ },
541
636
  "content": {
542
637
  "application/json": {
543
638
  "schema": {
@@ -565,6 +660,11 @@
565
660
  "responses": {
566
661
  "201": {
567
662
  "description": "paths.carrinhos.post.responses.201.description",
663
+ "headers": {
664
+ "X-ServeRest-Stages": {
665
+ "$ref": "#/components/headers/XServeRestStages"
666
+ }
667
+ },
568
668
  "content": {
569
669
  "application/json": {
570
670
  "schema": {
@@ -575,6 +675,11 @@
575
675
  },
576
676
  "400": {
577
677
  "description": "paths.carrinhos.post.responses.400.description",
678
+ "headers": {
679
+ "X-ServeRest-Stages": {
680
+ "$ref": "#/components/headers/XServeRestStages"
681
+ }
682
+ },
578
683
  "content": {
579
684
  "application/json": {
580
685
  "schema": {
@@ -616,6 +721,11 @@
616
721
  "responses": {
617
722
  "200": {
618
723
  "description": "paths.carrinhosById.get.responses.200.description",
724
+ "headers": {
725
+ "X-ServeRest-Stages": {
726
+ "$ref": "#/components/headers/XServeRestStages"
727
+ }
728
+ },
619
729
  "content": {
620
730
  "application/json": {
621
731
  "schema": {
@@ -626,6 +736,11 @@
626
736
  },
627
737
  "400": {
628
738
  "description": "paths.carrinhosById.get.responses.400.description",
739
+ "headers": {
740
+ "X-ServeRest-Stages": {
741
+ "$ref": "#/components/headers/XServeRestStages"
742
+ }
743
+ },
629
744
  "content": {
630
745
  "application/json": {
631
746
  "schema": {
@@ -658,6 +773,11 @@
658
773
  "responses": {
659
774
  "200": {
660
775
  "description": "paths.carrinhosConcluirCompra.delete.responses.200.description",
776
+ "headers": {
777
+ "X-ServeRest-Stages": {
778
+ "$ref": "#/components/headers/XServeRestStages"
779
+ }
780
+ },
661
781
  "content": {
662
782
  "application/json": {
663
783
  "schema": {
@@ -693,6 +813,11 @@
693
813
  "responses": {
694
814
  "200": {
695
815
  "description": "paths.carrinhosCancelarCompra.delete.responses.200.description",
816
+ "headers": {
817
+ "X-ServeRest-Stages": {
818
+ "$ref": "#/components/headers/XServeRestStages"
819
+ }
820
+ },
696
821
  "content": {
697
822
  "application/json": {
698
823
  "schema": {
@@ -894,6 +1019,11 @@
894
1019
  "responses": {
895
1020
  "EmailJaUtilizado": {
896
1021
  "description": "components.responses.EmailJaUtilizado.description",
1022
+ "headers": {
1023
+ "X-ServeRest-Stages": {
1024
+ "$ref": "#/components/headers/XServeRestStages"
1025
+ }
1026
+ },
897
1027
  "content": {
898
1028
  "application/json": {
899
1029
  "schema": {
@@ -904,6 +1034,11 @@
904
1034
  },
905
1035
  "ProdutoNomeDuplicado": {
906
1036
  "description": "components.responses.ProdutoNomeDuplicado.description",
1037
+ "headers": {
1038
+ "X-ServeRest-Stages": {
1039
+ "$ref": "#/components/headers/XServeRestStages"
1040
+ }
1041
+ },
907
1042
  "content": {
908
1043
  "application/json": {
909
1044
  "schema": {
@@ -914,6 +1049,11 @@
914
1049
  },
915
1050
  "TokenInvalido": {
916
1051
  "description": "components.responses.TokenInvalido.description",
1052
+ "headers": {
1053
+ "X-ServeRest-Stages": {
1054
+ "$ref": "#/components/headers/XServeRestStages"
1055
+ }
1056
+ },
917
1057
  "content": {
918
1058
  "application/json": {
919
1059
  "schema": {
@@ -924,6 +1064,11 @@
924
1064
  },
925
1065
  "RotaParaAdministradores": {
926
1066
  "description": "components.responses.RotaParaAdministradores.description",
1067
+ "headers": {
1068
+ "X-ServeRest-Stages": {
1069
+ "$ref": "#/components/headers/XServeRestStages"
1070
+ }
1071
+ },
927
1072
  "content": {
928
1073
  "application/json": {
929
1074
  "schema": {
@@ -933,6 +1078,15 @@
933
1078
  }
934
1079
  }
935
1080
  },
1081
+ "headers": {
1082
+ "XServeRestStages": {
1083
+ "description": "components.headers.XServeRestStages.description",
1084
+ "schema": {
1085
+ "type": "string",
1086
+ "example": "body;st=ok;dur=0.21, rate_limit;st=skipped, auth;st=failed;dur=0.05;reason=token_missing, schema;st=not_run, rules;st=not_run, write;st=not_run"
1087
+ }
1088
+ }
1089
+ },
936
1090
  "securitySchemes": {
937
1091
  "ApiKeyAuth": {
938
1092
  "type": "apiKey",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serverest",
3
- "version": "3.2.1",
3
+ "version": "3.3.0-beta.1",
4
4
  "description": "Servidor REST local de forma rápida e simples para estudo de testes de API",
5
5
  "author": "Paulo Gonçalves <author@serverest.dev> (https://www.linkedin.com/in/paulo-goncalves/)",
6
6
  "license": "GPL-3.0",
@@ -66,6 +66,7 @@
66
66
  "moesif-nodejs": "^3.11.0",
67
67
  "morgan": "^1.10.1",
68
68
  "nedb-promises": "^6.2.3",
69
+ "on-headers": "^1.1.0",
69
70
  "open": "^11.0.0",
70
71
  "rate-limiter-flexible": "^9.1.0",
71
72
  "swagger-ui-express": "^5.0.1",
package/src/app.js CHANGED
@@ -26,6 +26,7 @@ const moesifMiddleware = require('./middlewares/moesif-monitor-middleware')
26
26
  const { version } = require('../package.json')
27
27
  const swaggerDocument = require('../docs/swagger.json')
28
28
  const rateLimiter = require('./middlewares/rate-limiter')
29
+ const { iniciar, etapa } = require('./utils/etapas')
29
30
  const { fetchLatestRelease } = require('./utils/github-release')
30
31
  const { buildCustomJsStr, customCss } = require('./swagger')
31
32
  const { localizeSwaggerDocument } = require('./swagger/translations')
@@ -50,13 +51,20 @@ if (process.env.BLOCKED_IPS) {
50
51
  }
51
52
 
52
53
  app.set('json spaces', 4)
53
- app.use(express.json())
54
- app.use(express.urlencoded({ extended: false }))
54
+ // Confia em exatamente um proxy, que é o do Cloud Run. Sem isso req.ip resolve para o
55
+ // endereço do proxy e todos os clientes viram a mesma chave no rate limiter. Com 'true'
56
+ // o Express passaria a ler o primeiro endereço do X-Forwarded-For, que o próprio cliente
57
+ // pode forjar, então o número de saltos precisa ser explícito.
58
+ app.set('trust proxy', 1)
59
+ app.use(['/login', '/usuarios', '/produtos', '/carrinhos'], iniciar)
60
+ const parsersDoBody = express.Router()
61
+ parsersDoBody.use(express.json(), express.urlencoded({ extended: false }))
62
+ app.use(etapa('body', parsersDoBody))
55
63
  app.use(queryParser())
56
64
  app.use(timeout())
57
- app.use(cors())
65
+ app.use(cors({ exposedHeaders: ['X-ServeRest-Stages'] }))
58
66
  app.use(moesifMiddleware)
59
- app.use(rateLimiter)
67
+ app.use(etapa('rate_limit', rateLimiter))
60
68
 
61
69
  app.disable('etag')
62
70
 
@@ -18,7 +18,11 @@ exports.getOne = async (req, res) => {
18
18
  }
19
19
 
20
20
  exports.post = async (req, res) => {
21
+ const { etapas } = res.locals
22
+ etapas.regras('single_cart', 'no_duplicate_product', 'product_exists', 'enough_stock')
23
+
21
24
  const { idUsuario, possuiCarrinho } = await service.usuarioJaPossuiCarrinho(req.headers.authorization)
25
+ etapas.regra('single_cart', !possuiCarrinho)
22
26
  if (possuiCarrinho) {
23
27
  return res.status(400).send({ message: constant.LIMIT_JUST_ONE_CART })
24
28
  }
@@ -26,14 +30,26 @@ exports.post = async (req, res) => {
26
30
  const { produtos } = req.body
27
31
 
28
32
  const idProdutosDuplicados = service.extrairProdutosDuplicados(produtos)
33
+ etapas.regra('no_duplicate_product', !idProdutosDuplicados.length)
29
34
  if (idProdutosDuplicados.length) {
30
35
  return res.status(400).send({ message: constant.CART_WITH_DUPLICATE_PRODUCT, idProdutosDuplicados })
31
36
  }
32
37
 
33
38
  const { produtosComPrecoUnitario, error } = await service.getProdutosComPrecoUnitarioOuErro(produtos)
34
39
  if (error) {
40
+ // As duas regras são avaliadas item a item e param no primeiro item que falha.
41
+ if (error.message === constant.PRODUCT_NOT_FOUND) {
42
+ etapas.regra('product_exists', false, { item: error.index })
43
+ } else {
44
+ etapas.regra('product_exists', true)
45
+ etapas.regra('enough_stock', false, { item: error.index })
46
+ }
35
47
  return res.status(error.statusCode).send({ message: error.message, item: error.item })
36
48
  }
49
+ etapas.regra('product_exists', true)
50
+ etapas.regra('enough_stock', true)
51
+
52
+ etapas.abrir('write')
37
53
  const precoTotal = await service.precoTotal(produtosComPrecoUnitario)
38
54
  const quantidadeTotal = await service.quantidadeTotal(produtosComPrecoUnitario)
39
55
 
@@ -1,41 +1,45 @@
1
1
  'use strict'
2
2
 
3
+ const { TokenExpiredError } = require('jsonwebtoken')
4
+
3
5
  const authService = require('../services/auth-service')
4
6
  const constant = require('../utils/constants')
5
7
  const usuariosService = require('../services/usuarios-service')
8
+ const { etapa } = require('../utils/etapas')
6
9
 
7
- exports.checkAdm = async (req, res, next) => {
8
- if (!await tokenValido(req.headers)) {
10
+ exports.checkAdm = etapa('auth', async (req, res, next) => {
11
+ const motivo = await motivoDoTokenInvalido(req.headers)
12
+ if (motivo) {
13
+ res.locals.etapas.detalhar({ reason: motivo })
9
14
  return res.status(401).send({ message: constant.INVALID_TOKEN })
10
15
  }
11
16
  const tokenDecodificado = authService.verifyToken(req.headers.authorization)
12
17
  if (!await usuariosService.usuarioEhAdministrador(tokenDecodificado)) {
18
+ res.locals.etapas.detalhar({ reason: 'not_admin' })
13
19
  return res.status(403).send({ message: constant.REQUIRED_ADMIN })
14
20
  }
15
21
  next()
16
- }
22
+ })
17
23
 
18
- exports.checkToken = async (req, res, next) => {
19
- if (!await tokenValido(req.headers)) {
24
+ exports.checkToken = etapa('auth', async (req, res, next) => {
25
+ const motivo = await motivoDoTokenInvalido(req.headers)
26
+ if (motivo) {
27
+ res.locals.etapas.detalhar({ reason: motivo })
20
28
  return res.status(401).send({ message: constant.INVALID_TOKEN })
21
29
  }
22
30
  next()
23
- }
31
+ })
24
32
 
25
- async function tokenValido ({ authorization }) {
26
- if (authorization === undefined) return false
33
+ async function motivoDoTokenInvalido ({ authorization }) {
34
+ if (authorization === undefined) return 'token_missing'
27
35
 
28
- const semBearer = authorization.split(' ')[0] !== 'Bearer'
29
- const semToken = authorization.split(' ')[1] === undefined
30
-
31
- if (semBearer || semToken) {
32
- return false
33
- }
36
+ const [tipo, token] = authorization.split(' ')
37
+ if (tipo !== 'Bearer' || !token) return 'bad_format'
34
38
 
35
39
  const tokenDecodificado = authService.verifyToken(authorization)
36
- if (tokenDecodificado.email === undefined) {
37
- return false
38
- }
40
+ if (tokenDecodificado instanceof TokenExpiredError) return 'token_expired'
41
+ if (tokenDecodificado.email === undefined) return 'token_invalid'
39
42
 
40
- return usuariosService.existeUsuario({ email: tokenDecodificado.email, password: tokenDecodificado.password })
43
+ const usuario = await usuariosService.existeUsuario({ email: tokenDecodificado.email, password: tokenDecodificado.password })
44
+ if (!usuario) return 'user_not_found'
41
45
  }
@@ -6,21 +6,39 @@ const {
6
6
  } = require('../utils/ambiente')
7
7
  const { LOAD_TEST_DETECTED } = require('../utils/constants')
8
8
 
9
- const rateLimiter = new RateLimiterMemory({
10
- points: 600, // requests
11
- duration: 30 // segundo por IP
9
+ // Limite por cliente. Depende do 'trust proxy' definido no app.js: sem ele req.ip resolve
10
+ // para o endereço do proxy do Cloud Run e este limite passaria a valer para todos somados.
11
+ const limitePorIp = new RateLimiterMemory({
12
+ points: 300, // requests
13
+ duration: 30 // segundos, por IP
14
+ })
15
+
16
+ // Limite agregado do container. Existe porque o limite por IP, sozinho, não impõe teto ao
17
+ // total: bastariam vários clientes simultâneos para saturar a instância, que roda com
18
+ // 256 MiB e max-instances 1. Fica acima do limite por IP para que um único cliente abusivo
19
+ // seja barrado pelo próprio limite antes de consumir a cota que protege os demais.
20
+ const limiteGlobal = new RateLimiterMemory({
21
+ points: 900, // requests
22
+ duration: 30 // segundos, somando todos os clientes
12
23
  })
13
24
 
14
25
  module.exports = async (req, res, next) => {
15
26
  if (aplicacaoExecutandoLocalmente() || ehAmbienteDeTestes) {
27
+ res.locals.etapas?.ignorar()
16
28
  return next()
17
29
  }
18
30
 
19
- await rateLimiter.consume(req.ip)
20
- .then(() => next())
21
- .catch(() => {
22
- return res.status(429).send({
23
- message: LOAD_TEST_DETECTED
24
- })
31
+ try {
32
+ // O global vem primeiro de propósito. Avaliar os dois em paralelo faria a requisição
33
+ // consumir a cota individual mesmo quando o bloqueio viesse do limite agregado, o que
34
+ // penalizaria clientes inocentes durante congestionamento e, pior, criaria uma chave
35
+ // por endereço no mapa mesmo para requisições já rejeitadas.
36
+ await limiteGlobal.consume('global')
37
+ await limitePorIp.consume(req.ip)
38
+ return next()
39
+ } catch (_) {
40
+ return res.status(429).send({
41
+ message: LOAD_TEST_DETECTED
25
42
  })
43
+ }
26
44
  }
@@ -6,12 +6,13 @@ const authentication = require('../middlewares/authentication-middleware')
6
6
  const controller = require('../controllers/carrinhos-controller')
7
7
  const model = require('../models/carrinhos-model')
8
8
  const validateSchema = require('../services/validateSchema-service')
9
+ const { etapa, plano } = require('../utils/etapas')
9
10
 
10
11
  const router = express.Router()
11
- router.get('/', validateSchema(model.schemaGet), controller.get)
12
- router.get('/:id', validateSchema(model.schemaGetOne), controller.getOne)
13
- router.post('/', authentication.checkToken, validateSchema(model.schemaPost), controller.post)
14
- router.delete('/concluir-compra', authentication.checkToken, controller.concluirCompra)
15
- router.delete('/cancelar-compra', authentication.checkToken, controller.cancelarCompra)
12
+ router.get('/', plano('schema', 'read'), validateSchema(model.schemaGet), etapa('read', controller.get))
13
+ router.get('/:id', plano('schema', 'read'), validateSchema(model.schemaGetOne), etapa('read', controller.getOne))
14
+ router.post('/', plano('auth', 'schema', 'rules', 'write'), authentication.checkToken, validateSchema(model.schemaPost), etapa('rules', controller.post))
15
+ router.delete('/concluir-compra', plano('auth', 'rules'), authentication.checkToken, etapa('rules', controller.concluirCompra))
16
+ router.delete('/cancelar-compra', plano('auth', 'rules'), authentication.checkToken, etapa('rules', controller.cancelarCompra))
16
17
 
17
18
  module.exports = router
@@ -5,8 +5,9 @@ const express = require('express')
5
5
  const controller = require('../controllers/login-controller')
6
6
  const model = require('../models/login-model')
7
7
  const validateSchema = require('../services/validateSchema-service')
8
+ const { etapa, plano } = require('../utils/etapas')
8
9
 
9
10
  const router = express.Router()
10
- router.post('/', validateSchema(model.schemaPost), controller.post)
11
+ router.post('/', plano('schema', 'rules'), validateSchema(model.schemaPost), etapa('rules', controller.post))
11
12
 
12
13
  module.exports = router
@@ -6,12 +6,13 @@ const authentication = require('../middlewares/authentication-middleware')
6
6
  const controller = require('../controllers/produtos-controller')
7
7
  const model = require('../models/produtos-model')
8
8
  const validateSchema = require('../services/validateSchema-service')
9
+ const { etapa, plano } = require('../utils/etapas')
9
10
 
10
11
  const router = express.Router()
11
- router.get('/', validateSchema(model.schemaGet), controller.get)
12
- router.get('/:id', validateSchema(model.schemaGetOne), controller.getOne)
13
- router.post('/', authentication.checkAdm, validateSchema(model.schemaPost), controller.post)
14
- router.delete('/:id', authentication.checkAdm, validateSchema(model.schemaDelete), controller.delete)
15
- router.put('/:id', authentication.checkAdm, validateSchema(model.schemaPut), controller.put)
12
+ router.get('/', plano('schema', 'read'), validateSchema(model.schemaGet), etapa('read', controller.get))
13
+ router.get('/:id', plano('schema', 'read'), validateSchema(model.schemaGetOne), etapa('read', controller.getOne))
14
+ router.post('/', plano('auth', 'schema', 'rules'), authentication.checkAdm, validateSchema(model.schemaPost), etapa('rules', controller.post))
15
+ router.delete('/:id', plano('auth', 'schema', 'rules'), authentication.checkAdm, validateSchema(model.schemaDelete), etapa('rules', controller.delete))
16
+ router.put('/:id', plano('auth', 'schema', 'rules'), authentication.checkAdm, validateSchema(model.schemaPut), etapa('rules', controller.put))
16
17
 
17
18
  module.exports = router
@@ -5,12 +5,13 @@ const express = require('express')
5
5
  const controller = require('../controllers/usuarios-controller')
6
6
  const model = require('../models/usuarios-model')
7
7
  const validateSchema = require('../services/validateSchema-service')
8
+ const { etapa, plano } = require('../utils/etapas')
8
9
 
9
10
  const router = express.Router()
10
- router.get('/', validateSchema(model.schemaGet), controller.get)
11
- router.get('/:id', validateSchema(model.schemaGetOne), controller.getOne)
12
- router.post('/', validateSchema(model.schemaPost), controller.post)
13
- router.delete('/:id', validateSchema(model.schemaDelete), controller.delete)
14
- router.put('/:id', validateSchema(model.schemaPut), controller.put)
11
+ router.get('/', plano('schema', 'read'), validateSchema(model.schemaGet), etapa('read', controller.get))
12
+ router.get('/:id', plano('schema', 'read'), validateSchema(model.schemaGetOne), etapa('read', controller.getOne))
13
+ router.post('/', plano('schema', 'rules'), validateSchema(model.schemaPost), etapa('rules', controller.post))
14
+ router.delete('/:id', plano('schema', 'rules'), validateSchema(model.schemaDelete), etapa('rules', controller.delete))
15
+ router.put('/:id', plano('schema', 'rules'), validateSchema(model.schemaPut), etapa('rules', controller.put))
15
16
 
16
17
  module.exports = router
package/src/server.js CHANGED
@@ -7,16 +7,57 @@ require('dotenv').config({ path: path.resolve(__dirname, '../.env'), quiet: true
7
7
 
8
8
  const colors = require('colors')
9
9
  const debug = require('debug')('nodestr:server')
10
+ const fs = require('fs')
10
11
  const http = require('http')
12
+ const util = require('util')
11
13
  const open = require('open').default
12
14
 
13
15
  const { version } = require('../package.json')
14
16
  const { formaDeExecucao, urlDocumentacao, aplicacaoExecutandoLocalmente } = require('./utils/ambiente')
15
17
  const { conf } = require('./utils/conf')
16
18
  const getRandomFinancialContributor = require('./utils/getRandomFinancialContributor')
19
+ const { log } = require('./utils/logger')
17
20
 
18
21
  const DEFAULT_PORT = 3000
19
22
 
23
+ // Erros fora do ciclo de uma requisição encerram o processo sem deixar registro da causa,
24
+ // o que no Cloud Run aparece apenas como um container reiniciando sem explicação.
25
+ /* istanbul ignore next */
26
+ process.on('uncaughtException', valor => {
27
+ // Node permite lançar qualquer valor, não só Error. Sem esta normalização, um throw de
28
+ // string ou null faria o próprio handler lançar ao acessar .code, perdendo o registro
29
+ // da causa, que é justamente o que estes handlers existem para garantir.
30
+ const erro = valor instanceof Error ? valor : new Error(util.inspect(valor))
31
+
32
+ // Responder a uma requisição já encerrada é inofensivo: a resposta chegou ao cliente e o
33
+ // processo segue íntegro. Derrubar o servidor por isso custaria uma indisponibilidade
34
+ // inteira para corrigir nada.
35
+ if (erro.code === 'ERR_HTTP_HEADERS_SENT') {
36
+ log({ level: 'error', message: `Tentativa de responder requisição já encerrada: ${erro.message}` })
37
+ return
38
+ }
39
+
40
+ encerrar(`Exceção não capturada: ${erro.message}. Stack: ${erro.stack}`)
41
+ })
42
+
43
+ /* istanbul ignore next */
44
+ process.on('unhandledRejection', motivo => {
45
+ encerrar(`Promise rejeitada sem tratamento: ${motivo instanceof Error ? motivo.stack : util.inspect(motivo)}`)
46
+ })
47
+
48
+ // Sai de imediato em vez de drenar as conexões abertas. Com max-instances 1 no Cloud Run
49
+ // não existe outra instância para atender quem chegar durante a drenagem, então esperar
50
+ // só adiaria a subida do container substituto e prolongaria a indisponibilidade.
51
+ /* istanbul ignore next */
52
+ function encerrar (mensagem) {
53
+ // console.log é assíncrono quando stdout é um pipe, caso do Cloud Run, e process.exit
54
+ // não drena writes pendentes. A escrita síncrona no stderr garante que a causa fique
55
+ // registrada mesmo que o log estruturado seja truncado pelo encerramento.
56
+ fs.writeSync(2, `${mensagem}\n`)
57
+ log({ level: 'error', message: mensagem })
58
+ process.exit(1)
59
+ }
60
+
20
61
  const yargs = require('yargs/yargs')
21
62
  const { hideBin } = require('yargs/helpers')
22
63
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  const { validate } = require('express-validation')
4
4
 
5
+ const { etapa } = require('../utils/etapas')
6
+
5
7
  module.exports = schema => {
6
- return validate(schema, { keyByField: false }, { abortEarly: false })
8
+ return etapa('schema', validate(schema, { keyByField: false }, { abortEarly: false }))
7
9
  }
@@ -93,5 +93,6 @@ module.exports = {
93
93
  'components.responses.ProdutoNomeDuplicado.description': 'A product with this name already exists',
94
94
  'components.responses.TokenInvalido.description': 'Token missing, invalid or expired',
95
95
  'components.responses.RotaParaAdministradores.description': 'Route exclusive to administrators (`administrador = true`)',
96
+ 'components.headers.XServeRestStages.description': 'Stages of the route in execution order. Each entry has `st` (`ok`, `failed`, `skipped` or `not_run`), `dur` in milliseconds when the stage ran, `reason` when `auth` fails and `item` when a rule fails on a specific item. Entries named `<stage>.<rule>` detail the rules of that stage. Full format in the project README.',
96
97
  'components.securitySchemes.ApiKeyAuth.description': 'Provide the `Authorization` value returned by the _/login_ route'
97
98
  }
@@ -93,5 +93,6 @@ module.exports = {
93
93
  'components.responses.ProdutoNomeDuplicado.description': 'Ya existe un producto con ese nombre',
94
94
  'components.responses.TokenInvalido.description': 'Token ausente, inválido o expirado',
95
95
  'components.responses.RotaParaAdministradores.description': 'Ruta exclusiva para administradores (`administrador = true`)',
96
+ 'components.headers.XServeRestStages.description': 'Etapas de la ruta en orden de ejecución. Cada entrada tiene `st` (`ok`, `failed`, `skipped` o `not_run`), `dur` en milisegundos cuando la etapa se ejecutó, `reason` cuando `auth` falla e `item` cuando una regla falla en un ítem específico. Las entradas `<etapa>.<regla>` detallan las reglas de la etapa. Formato completo en el README del proyecto.',
96
97
  'components.securitySchemes.ApiKeyAuth.description': 'Indica el valor de `Authorization` devuelto por la ruta _/login_'
97
98
  }
@@ -93,5 +93,6 @@ module.exports = {
93
93
  'components.responses.ProdutoNomeDuplicado.description': 'Já existe produto com esse nome',
94
94
  'components.responses.TokenInvalido.description': 'Token ausente, inválido ou expirado',
95
95
  'components.responses.RotaParaAdministradores.description': 'Rota exclusiva para administradores (`administrador = true`)',
96
+ 'components.headers.XServeRestStages.description': 'Etapas da rota em ordem de execução. Cada entrada tem `st` (`ok`, `failed`, `skipped` ou `not_run`), `dur` em milissegundos quando a etapa executou, `reason` quando `auth` falha e `item` quando uma regra falha em um item específico. Entradas `<etapa>.<regra>` detalham as regras da etapa. Formato completo no README do projeto.',
96
97
  'components.securitySchemes.ApiKeyAuth.description': 'Informe o valor de `Authorization` retornado na rota _/login_'
97
98
  }
@@ -0,0 +1,98 @@
1
+ 'use strict'
2
+
3
+ const onHeaders = require('on-headers')
4
+
5
+ const emMilissegundos = nanossegundos => (Number(nanossegundos) / 1e6).toFixed(2)
6
+
7
+ class Registro {
8
+ constructor () {
9
+ this.entradas = []
10
+ this.regrasDeclaradas = new Map()
11
+ this.aberta = undefined
12
+ }
13
+
14
+ planejar (nomes) {
15
+ nomes.forEach(nome => this.entradas.push({ nome, st: 'not_run', detalhes: {} }))
16
+ }
17
+
18
+ abrir (nome) {
19
+ this.fechar('ok')
20
+ this.aberta = this.entradas.find(entrada => entrada.nome === nome)
21
+ this.aberta.inicio = process.hrtime.bigint()
22
+ }
23
+
24
+ fechar (st) {
25
+ if (!this.aberta) return
26
+ this.aberta.st = st
27
+ this.aberta.dur = emMilissegundos(process.hrtime.bigint() - this.aberta.inicio)
28
+ this.aberta = undefined
29
+ }
30
+
31
+ ignorar () {
32
+ this.aberta.st = 'skipped'
33
+ this.aberta = undefined
34
+ }
35
+
36
+ detalhar (objeto) {
37
+ Object.assign(this.aberta.detalhes, objeto)
38
+ }
39
+
40
+ regras (...nomes) {
41
+ const novas = nomes.map(nome => {
42
+ const entrada = { nome: `${this.aberta.nome}.${nome}`, st: 'not_run', detalhes: {} }
43
+ this.regrasDeclaradas.set(nome, entrada)
44
+ return entrada
45
+ })
46
+ this.entradas.splice(this.entradas.indexOf(this.aberta) + 1, 0, ...novas)
47
+ }
48
+
49
+ regra (nome, passou, detalhes) {
50
+ const entrada = this.regrasDeclaradas.get(nome)
51
+ entrada.st = passou ? 'ok' : 'failed'
52
+ Object.assign(entrada.detalhes, detalhes)
53
+ }
54
+
55
+ // A etapa ainda aberta quando os headers são escritos é a que produziu a resposta.
56
+ finalizar (statusCode) {
57
+ this.fechar(statusCode >= 400 ? 'failed' : 'ok')
58
+ }
59
+
60
+ stages () {
61
+ return this.entradas
62
+ .map(({ nome, st, dur, detalhes }) => [
63
+ nome,
64
+ `st=${st}`,
65
+ ...(dur === undefined ? [] : [`dur=${dur}`]),
66
+ ...Object.entries(detalhes).map(([chave, valor]) => `${chave}=${valor}`)
67
+ ].join(';'))
68
+ .join(', ')
69
+ }
70
+ }
71
+
72
+ exports.iniciar = (req, res, next) => {
73
+ // O cors responde o preflight antes do rate limiter, então não há etapa a relatar.
74
+ if (req.method === 'OPTIONS') return next()
75
+ const registro = res.locals.etapas = new Registro()
76
+ registro.planejar(['body', 'rate_limit'])
77
+ onHeaders(res, () => {
78
+ registro.finalizar(res.statusCode)
79
+ res.setHeader('X-ServeRest-Stages', registro.stages())
80
+ })
81
+ next()
82
+ }
83
+
84
+ // Exatamente três parâmetros: com quatro o Express trataria o retorno como error handler.
85
+ exports.etapa = (nome, middleware) => (req, res, next) => {
86
+ const registro = res.locals.etapas
87
+ if (!registro) return middleware(req, res, next)
88
+ registro.abrir(nome)
89
+ return middleware(req, res, erro => {
90
+ registro.fechar(erro ? 'failed' : 'ok')
91
+ next(erro)
92
+ })
93
+ }
94
+
95
+ exports.plano = (...nomes) => (req, res, next) => {
96
+ res.locals.etapas.planejar(nomes)
97
+ next()
98
+ }