├── .docker ├── docker-compose.yml ├── grafana │ └── provisioning │ │ ├── dashboards │ │ ├── Docker Prometheus Monitoring - 2.json │ │ └── dashboard.yml │ │ └── datasources │ │ └── all.yml ├── nginx │ └── nginx.conf └── prometheus │ ├── alert.rules │ └── prometheus.yml ├── .github └── workflows │ ├── backend-realtime.yaml │ ├── backend.yml │ ├── codeql-analysis.yml │ └── frontend.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── backend-realtime ├── .dockerignore ├── .env ├── .eslintignore ├── .eslintrc ├── .npmrc ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── package.json ├── src │ ├── app.ts │ ├── common │ │ ├── environment.ts │ │ ├── errors │ │ │ └── operation-error.ts │ │ ├── logger.ts │ │ ├── monitoring │ │ │ └── index.ts │ │ ├── path-normalizer.ts │ │ └── timer.ts │ ├── kafka │ │ └── index.ts │ ├── register-routes.ts │ ├── server.ts │ ├── socket.ts │ └── start.ts ├── tsconfig.json └── yarn.lock ├── backend ├── .dockerignore ├── .env ├── .eslintignore ├── .eslintrc ├── .npmrc ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── data │ └── .gitkeep ├── package.json ├── src │ ├── app.ts │ ├── common │ │ ├── environment.ts │ │ ├── errors │ │ │ ├── db-error.ts │ │ │ └── operation-error.ts │ │ ├── logger.ts │ │ ├── monitoring │ │ │ └── index.ts │ │ ├── path-normalizer.ts │ │ └── timer.ts │ ├── controller │ │ └── todo.ts │ ├── kafka │ │ └── index.ts │ ├── redis │ │ └── index.ts │ ├── register-routes.ts │ ├── repository │ │ ├── base.ts │ │ ├── database.ts │ │ ├── migrations │ │ │ └── 1606672821869-insert-users.ts │ │ ├── model │ │ │ ├── base.ts │ │ │ └── todo.ts │ │ └── todo.ts │ ├── routes.ts │ ├── server.ts │ ├── service │ │ └── todo.ts │ ├── start.ts │ └── swagger.json ├── tsconfig.json ├── tsoa.json └── yarn.lock ├── clean.sh ├── frontend ├── .dockerignore ├── .gitignore ├── .npmrc ├── .prettierrc ├── Dockerfile ├── README.md ├── nginx │ └── default.conf ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.tsx │ ├── components │ │ ├── ErrorFallback │ │ │ ├── index.tsx │ │ │ └── style.ts │ │ └── Navigation │ │ │ ├── index.tsx │ │ │ └── style.ts │ ├── index.tsx │ ├── pages │ │ ├── About │ │ │ ├── index.tsx │ │ │ └── style.ts │ │ └── Home │ │ │ ├── components │ │ │ ├── AddDialog │ │ │ │ ├── index.tsx │ │ │ │ └── style.ts │ │ │ ├── DeleteDialog │ │ │ │ └── index.tsx │ │ │ └── RealtimeDrawer │ │ │ │ ├── index.tsx │ │ │ │ └── style.ts │ │ │ ├── helpers │ │ │ └── index.ts │ │ │ ├── index.tsx │ │ │ └── style.ts │ ├── react-app-env.d.ts │ ├── setupTests.ts │ ├── theme │ │ └── index.tsx │ └── utils │ │ └── axios.ts ├── tsconfig.json └── yarn.lock ├── package.json ├── readme.md ├── setup.sh ├── src ├── generate_client │ └── index.js └── load_testing │ └── hurtMe.js ├── tsconfig.json └── yarn.lock /.docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | 3 | services: 4 | # -- NGINX 5 | nginx: 6 | container_name: nginx_proxy 7 | image: nginx:stable-alpine 8 | ports: 9 | - '80:80' 10 | environment: 11 | BACKEND_URL: http://backend 12 | volumes: 13 | - ./nginx/nginx.conf:/etc/nginx/nginx.conf 14 | depends_on: 15 | - backend 16 | - frontend 17 | restart: unless-stopped 18 | # -- Frontend 19 | frontend: 20 | container_name: frontend_app 21 | image: raedchammam/demo-project-frontend 22 | # uncomment to build image 23 | # build: 24 | # context: ../frontend 25 | # dockerfile: Dockerfile 26 | ports: 27 | - '8080:8080' 28 | restart: unless-stopped 29 | # -- Backend API 30 | backend: 31 | container_name: backend_api 32 | image: raedchammam/demo-project-backend 33 | # uncomment to build image 34 | # build: 35 | # context: ../backend 36 | # dockerfile: Dockerfile 37 | environment: 38 | NODE_ENV: prod 39 | PORT: 3000 40 | LOCAL_DOCKER: "false" 41 | LOG_LEVEL: debug 42 | PG_HOST: postgres_db 43 | PG_PORT: 5432 44 | PG_USERNAME: postgres 45 | PG_PASSWORD: changeme 46 | REDIS_HOST: redis_cache 47 | REDIS_PORT: 6379 48 | KAFKA_HOST: kafka 49 | KAFKA_PORT: 29092 50 | ports: 51 | - '3000:3000' 52 | links: 53 | - postgres_db 54 | - redis_cache 55 | depends_on: 56 | - postgres_db 57 | - redis_cache 58 | - kafka 59 | restart: unless-stopped 60 | # -- Backend Realtime API 61 | backend-realtime: 62 | container_name: backend-realtime_api 63 | image: raedchammam/demo-project-backend-realtime 64 | # uncomment to build image 65 | # build: 66 | # context: ../backend-realtime 67 | # dockerfile: Dockerfile 68 | environment: 69 | NODE_ENV: prod 70 | PORT: 3001 71 | KAFKA_HOST: kafka 72 | KAFKA_PORT: 29092 73 | ports: 74 | - '3001:3001' 75 | depends_on: 76 | - kafka 77 | restart: unless-stopped 78 | # -- Database 79 | postgres_db: 80 | image: postgres:13-alpine 81 | environment: 82 | POSTGRES_USER: ${POSTGRES_USER:-postgres} 83 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} 84 | PGDATA: /data/postgres 85 | volumes: 86 | - postgres_data:/data/postgres 87 | ports: 88 | - '5432:5432' 89 | restart: unless-stopped 90 | # -- Redis cache 91 | redis_cache: 92 | container_name: redis_cache 93 | image: redis:6-alpine 94 | # comment it out for logging 95 | logging: 96 | driver: none 97 | ports: 98 | - '6379:6379' 99 | # -- Zookeeper 100 | zookeeper: 101 | image: confluentinc/cp-zookeeper:5.4.3 102 | hostname: zookeeper 103 | container_name: zookeeper 104 | ports: 105 | - '2181:2181' 106 | environment: 107 | ZOOKEEPER_CLIENT_PORT: '2181' 108 | ZOOKEEPER_TICK_TIME: '2000' 109 | # -- Kafka 110 | kafka: 111 | image: confluentinc/cp-kafka:5.4.2 112 | hostname: kafka 113 | container_name: kafka 114 | labels: 115 | - 'custom.service=kafka' 116 | depends_on: 117 | - zookeeper 118 | ports: 119 | - '29092:29092' 120 | - '9092:9092' 121 | environment: 122 | KAFKA_BROKER_ID: '0' 123 | KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181' 124 | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT 125 | KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 126 | KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true' 127 | KAFKA_DELETE_TOPIC_ENABLE: 'true' 128 | KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: '0' 129 | KAFKA_ALLOW_EVERYONE_IF_NO_ACL_FOUND: 'true' 130 | KAFKA_LOG4J_LOGGERS: 'kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO' 131 | CONFLUENT_SUPPORT_METRICS_ENABLE: 'false' 132 | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 133 | # -- Prometheus 134 | prometheus: 135 | image: prom/prometheus:v2.23.0 136 | container_name: prometheus 137 | ports: 138 | - 9090:9090 139 | command: 140 | - --config.file=/etc/prometheus/prometheus.yml 141 | - --web.enable-lifecycle 142 | volumes: 143 | - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro 144 | # -- Grafana 145 | grafana: 146 | image: grafana/grafana:7.3.4 147 | container_name: grafana 148 | links: 149 | - prometheus:prometheus 150 | volumes: 151 | - grafana_data:/var/lib/grafana 152 | - ./grafana/provisioning/:/etc/grafana/provisioning/ 153 | ports: 154 | - 3003:3000 155 | environment: 156 | - GF_AUTH_ANONYMOUS_ENABLED=true 157 | - GF_AUTH_ORG_ROLE=Admin 158 | - GF_SMTP_ENABLED=false 159 | restart: unless-stopped 160 | volumes: 161 | postgres_data: 162 | grafana_data: 163 | -------------------------------------------------------------------------------- /.docker/grafana/provisioning/dashboards/Docker Prometheus Monitoring - 2.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": "-- Grafana --", 7 | "enable": true, 8 | "hide": true, 9 | "iconColor": "rgba(0, 211, 255, 1)", 10 | "name": "Annotations & Alerts", 11 | "type": "dashboard" 12 | } 13 | ] 14 | }, 15 | "description": "Application metrics", 16 | "editable": true, 17 | "gnetId": null, 18 | "graphTooltip": 0, 19 | "links": [], 20 | "panels": [ 21 | { 22 | "collapsed": false, 23 | "datasource": null, 24 | "gridPos": { 25 | "h": 1, 26 | "w": 24, 27 | "x": 0, 28 | "y": 0 29 | }, 30 | "id": 7, 31 | "panels": [], 32 | "repeat": null, 33 | "title": "Main", 34 | "type": "row" 35 | }, 36 | { 37 | "cacheTimeout": null, 38 | "colorBackground": false, 39 | "colorPostfix": false, 40 | "colorPrefix": false, 41 | "colorValue": true, 42 | "colors": [ 43 | "rgba(245, 54, 54, 0.9)", 44 | "rgba(237, 129, 40, 0.89)", 45 | "rgba(50, 172, 45, 0.97)" 46 | ], 47 | "datasource": null, 48 | "decimals": null, 49 | "fieldConfig": { 50 | "defaults": { 51 | "custom": {} 52 | }, 53 | "overrides": [] 54 | }, 55 | "format": "none", 56 | "gauge": { 57 | "maxValue": 1, 58 | "minValue": 0, 59 | "show": false, 60 | "thresholdLabels": true, 61 | "thresholdMarkers": true 62 | }, 63 | "gridPos": { 64 | "h": 8, 65 | "w": 5, 66 | "x": 0, 67 | "y": 1 68 | }, 69 | "hideTimeOverride": false, 70 | "id": 6, 71 | "interval": null, 72 | "links": [], 73 | "mappingType": 1, 74 | "mappingTypes": [ 75 | { 76 | "$$hashKey": "object:121", 77 | "name": "value to text", 78 | "value": 1 79 | }, 80 | { 81 | "$$hashKey": "object:122", 82 | "name": "range to text", 83 | "value": 2 84 | } 85 | ], 86 | "maxDataPoints": 100, 87 | "nullPointMode": "connected", 88 | "nullText": null, 89 | "postfix": "", 90 | "postfixFontSize": "50%", 91 | "prefix": "", 92 | "prefixFontSize": "50%", 93 | "rangeMaps": [ 94 | { 95 | "from": "null", 96 | "text": "N/A", 97 | "to": "null" 98 | } 99 | ], 100 | "sparkline": { 101 | "fillColor": "rgba(31, 118, 189, 0.18)", 102 | "full": true, 103 | "lineColor": "rgb(31, 120, 193)", 104 | "show": true 105 | }, 106 | "tableColumn": "redis_cache_hit_counter{counter=\"1\", group=\"backend\", instance=\"host.docker.internal:3000\", job=\"prometheus\", service=\"backend-demo-app\"}", 107 | "targets": [ 108 | { 109 | "expr": "redis_cache_hit_counter", 110 | "format": "time_series", 111 | "interval": "", 112 | "intervalFactor": 2, 113 | "legendFormat": "", 114 | "refId": "A", 115 | "step": 20 116 | } 117 | ], 118 | "thresholds": "0.1", 119 | "title": "Redis cache hit", 120 | "type": "singlestat", 121 | "valueFontSize": "80%", 122 | "valueMaps": [ 123 | { 124 | "$$hashKey": "object:124", 125 | "op": "=", 126 | "text": "N/A", 127 | "value": "null" 128 | } 129 | ], 130 | "valueName": "avg" 131 | }, 132 | { 133 | "aliasColors": {}, 134 | "bars": false, 135 | "dashLength": 10, 136 | "dashes": false, 137 | "datasource": null, 138 | "description": "", 139 | "fieldConfig": { 140 | "defaults": { 141 | "custom": { 142 | "align": null, 143 | "filterable": false 144 | }, 145 | "mappings": [], 146 | "thresholds": { 147 | "mode": "absolute", 148 | "steps": [ 149 | { 150 | "color": "green", 151 | "value": null 152 | }, 153 | { 154 | "color": "red", 155 | "value": 80 156 | } 157 | ] 158 | } 159 | }, 160 | "overrides": [] 161 | }, 162 | "fill": 1, 163 | "fillGradient": 0, 164 | "gridPos": { 165 | "h": 8, 166 | "w": 12, 167 | "x": 6, 168 | "y": 1 169 | }, 170 | "hiddenSeries": false, 171 | "id": 16, 172 | "legend": { 173 | "avg": false, 174 | "current": false, 175 | "max": false, 176 | "min": false, 177 | "show": true, 178 | "total": false, 179 | "values": false 180 | }, 181 | "lines": true, 182 | "linewidth": 1, 183 | "nullPointMode": "null", 184 | "options": { 185 | "alertThreshold": true 186 | }, 187 | "percentage": false, 188 | "pluginVersion": "7.3.4", 189 | "pointradius": 2, 190 | "points": false, 191 | "renderer": "flot", 192 | "seriesOverrides": [], 193 | "spaceLength": 10, 194 | "stack": false, 195 | "steppedLine": false, 196 | "targets": [ 197 | { 198 | "expr": "histogram_quantile(0.5, sum(rate(http_request_duration_ms_bucket[1m])) by (le, service, route, method))", 199 | "interval": "", 200 | "legendFormat": "", 201 | "refId": "A" 202 | } 203 | ], 204 | "thresholds": [], 205 | "timeFrom": null, 206 | "timeRegions": [], 207 | "timeShift": null, 208 | "title": "Median Response Time", 209 | "tooltip": { 210 | "shared": true, 211 | "sort": 0, 212 | "value_type": "individual" 213 | }, 214 | "type": "graph", 215 | "xaxis": { 216 | "buckets": null, 217 | "mode": "time", 218 | "name": null, 219 | "show": true, 220 | "values": [] 221 | }, 222 | "yaxes": [ 223 | { 224 | "format": "short", 225 | "label": null, 226 | "logBase": 1, 227 | "max": null, 228 | "min": null, 229 | "show": true 230 | }, 231 | { 232 | "format": "short", 233 | "label": null, 234 | "logBase": 1, 235 | "max": null, 236 | "min": null, 237 | "show": true 238 | } 239 | ], 240 | "yaxis": { 241 | "align": false, 242 | "alignLevel": null 243 | } 244 | }, 245 | { 246 | "aliasColors": {}, 247 | "bars": false, 248 | "dashLength": 10, 249 | "dashes": false, 250 | "datasource": null, 251 | "fieldConfig": { 252 | "defaults": { 253 | "custom": {} 254 | }, 255 | "overrides": [] 256 | }, 257 | "fill": 1, 258 | "fillGradient": 0, 259 | "gridPos": { 260 | "h": 8, 261 | "w": 12, 262 | "x": 6, 263 | "y": 9 264 | }, 265 | "hiddenSeries": false, 266 | "id": 12, 267 | "legend": { 268 | "avg": false, 269 | "current": false, 270 | "max": false, 271 | "min": false, 272 | "show": true, 273 | "total": false, 274 | "values": false 275 | }, 276 | "lines": true, 277 | "linewidth": 1, 278 | "nullPointMode": "null", 279 | "options": { 280 | "alertThreshold": true 281 | }, 282 | "percentage": false, 283 | "pluginVersion": "7.3.4", 284 | "pointradius": 2, 285 | "points": false, 286 | "renderer": "flot", 287 | "seriesOverrides": [], 288 | "spaceLength": 10, 289 | "stack": false, 290 | "steppedLine": false, 291 | "targets": [ 292 | { 293 | "expr": "(\n sum(rate(http_request_duration_ms_bucket{le=\"100\"}[1m])) by (service)\n+\n sum(rate(http_request_duration_ms_bucket{le=\"300\"}[1m])) by (service)\n) / 2 / sum(rate(http_request_duration_ms_count[1m])) by (service)", 294 | "interval": "", 295 | "legendFormat": "", 296 | "refId": "A" 297 | } 298 | ], 299 | "thresholds": [], 300 | "timeFrom": null, 301 | "timeRegions": [], 302 | "timeShift": null, 303 | "title": "Apdex", 304 | "tooltip": { 305 | "shared": true, 306 | "sort": 0, 307 | "value_type": "individual" 308 | }, 309 | "type": "graph", 310 | "xaxis": { 311 | "buckets": null, 312 | "mode": "time", 313 | "name": null, 314 | "show": true, 315 | "values": [] 316 | }, 317 | "yaxes": [ 318 | { 319 | "format": "short", 320 | "label": null, 321 | "logBase": 1, 322 | "max": null, 323 | "min": null, 324 | "show": true 325 | }, 326 | { 327 | "format": "short", 328 | "label": null, 329 | "logBase": 1, 330 | "max": null, 331 | "min": null, 332 | "show": true 333 | } 334 | ], 335 | "yaxis": { 336 | "align": false, 337 | "alignLevel": null 338 | } 339 | }, 340 | { 341 | "aliasColors": {}, 342 | "bars": false, 343 | "dashLength": 10, 344 | "dashes": false, 345 | "datasource": null, 346 | "fieldConfig": { 347 | "defaults": { 348 | "custom": {}, 349 | "unit": "decmbytes" 350 | }, 351 | "overrides": [] 352 | }, 353 | "fill": 1, 354 | "fillGradient": 10, 355 | "gridPos": { 356 | "h": 8, 357 | "w": 12, 358 | "x": 6, 359 | "y": 17 360 | }, 361 | "hiddenSeries": false, 362 | "id": 14, 363 | "legend": { 364 | "avg": false, 365 | "current": false, 366 | "max": false, 367 | "min": false, 368 | "show": true, 369 | "total": false, 370 | "values": false 371 | }, 372 | "lines": true, 373 | "linewidth": 1, 374 | "nullPointMode": "null", 375 | "options": { 376 | "alertThreshold": true 377 | }, 378 | "percentage": false, 379 | "pluginVersion": "7.3.4", 380 | "pointradius": 2, 381 | "points": false, 382 | "renderer": "flot", 383 | "seriesOverrides": [], 384 | "spaceLength": 10, 385 | "stack": false, 386 | "steppedLine": true, 387 | "targets": [ 388 | { 389 | "expr": "avg(nodejs_external_memory_bytes / 1024 / 1024) by (service)", 390 | "format": "table", 391 | "instant": false, 392 | "interval": "", 393 | "legendFormat": "", 394 | "refId": "A" 395 | } 396 | ], 397 | "thresholds": [], 398 | "timeFrom": null, 399 | "timeRegions": [], 400 | "timeShift": null, 401 | "title": "Memory Usage", 402 | "tooltip": { 403 | "shared": true, 404 | "sort": 0, 405 | "value_type": "individual" 406 | }, 407 | "type": "graph", 408 | "xaxis": { 409 | "buckets": null, 410 | "mode": "time", 411 | "name": null, 412 | "show": true, 413 | "values": [] 414 | }, 415 | "yaxes": [ 416 | { 417 | "$$hashKey": "object:153", 418 | "format": "decmbytes", 419 | "label": null, 420 | "logBase": 1, 421 | "max": null, 422 | "min": null, 423 | "show": true 424 | }, 425 | { 426 | "$$hashKey": "object:154", 427 | "format": "short", 428 | "label": null, 429 | "logBase": 1, 430 | "max": null, 431 | "min": null, 432 | "show": true 433 | } 434 | ], 435 | "yaxis": { 436 | "align": false, 437 | "alignLevel": null 438 | } 439 | } 440 | ], 441 | "refresh": "5s", 442 | "schemaVersion": 26, 443 | "style": "dark", 444 | "tags": [], 445 | "templating": { 446 | "list": [] 447 | }, 448 | "time": { 449 | "from": "now-15m", 450 | "to": "now" 451 | }, 452 | "timepicker": { 453 | "refresh_intervals": [ 454 | "5s", 455 | "10s", 456 | "30s", 457 | "1m", 458 | "5m", 459 | "15m", 460 | "30m", 461 | "1h", 462 | "2h", 463 | "1d" 464 | ], 465 | "time_options": [ 466 | "5m", 467 | "15m", 468 | "1h", 469 | "6h", 470 | "12h", 471 | "24h", 472 | "2d", 473 | "7d", 474 | "30d" 475 | ] 476 | }, 477 | "timezone": "browser", 478 | "title": "Application metrics", 479 | "uid": "xWDzB1AGz", 480 | "version": 7 481 | } -------------------------------------------------------------------------------- /.docker/grafana/provisioning/dashboards/dashboard.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: 'default' 5 | orgId: 1 6 | folder: '' 7 | type: file 8 | disableDeletion: false 9 | editable: true 10 | updateIntervalSeconds: 10 11 | allowUiUpdates: true 12 | options: 13 | path: /etc/grafana/provisioning/dashboards -------------------------------------------------------------------------------- /.docker/grafana/provisioning/datasources/all.yml: -------------------------------------------------------------------------------- 1 | # config file version 2 | apiVersion: 1 3 | 4 | # list of datasources that should be deleted from the database 5 | deleteDatasources: 6 | - name: Prometheus 7 | orgId: 1 8 | 9 | # list of datasources to insert/update depending 10 | # whats available in the database 11 | datasources: 12 | # name of the datasource. Required 13 | - name: Prometheus 14 | # datasource type. Required 15 | type: prometheus 16 | # access mode. direct or proxy. Required 17 | access: proxy 18 | # org id. will default to orgId 1 if not specified 19 | orgId: 1 20 | # url 21 | url: http://host.docker.internal:9090 22 | # database password, if used 23 | password: 24 | # database user, if used 25 | user: 26 | # database name, if used 27 | database: 28 | # enable/disable basic auth 29 | basicAuth: false 30 | # basic auth username, if used 31 | basicAuthUser: 32 | # basic auth password, if used 33 | basicAuthPassword: 34 | # enable/disable with credentials headers 35 | withCredentials: 36 | # mark as default datasource. Max one per org 37 | isDefault: true 38 | # fields that will be converted to json and stored in json_data 39 | jsonData: 40 | graphiteVersion: "1.1" 41 | tlsAuth: false 42 | tlsAuthWithCACert: false 43 | # json object of data that will be encrypted. 44 | secureJsonData: 45 | tlsCACert: "..." 46 | tlsClientCert: "..." 47 | tlsClientKey: "..." 48 | version: 1 49 | # allow users to edit datasources from the UI. 50 | editable: true -------------------------------------------------------------------------------- /.docker/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | } 3 | 4 | http { 5 | 6 | upstream frontend { 7 | server frontend:8080; 8 | } 9 | 10 | upstream backend { 11 | server backend:3000; 12 | } 13 | 14 | upstream backend-realtime { 15 | server backend-realtime:3001; 16 | } 17 | 18 | server { 19 | listen 80; 20 | 21 | location / { 22 | proxy_pass http://frontend; 23 | proxy_redirect off; 24 | } 25 | 26 | location /api { 27 | proxy_pass http://backend; 28 | proxy_redirect off; 29 | } 30 | 31 | location /wss/ { 32 | proxy_pass http://backend-realtime; 33 | proxy_http_version 1.1; 34 | proxy_set_header Upgrade $http_upgrade; 35 | proxy_set_header Connection "Upgrade"; 36 | proxy_set_header Host $host; 37 | } 38 | 39 | include /etc/nginx/extra-conf.d/*.conf; 40 | } 41 | } -------------------------------------------------------------------------------- /.docker/prometheus/alert.rules: -------------------------------------------------------------------------------- 1 | # APIHighMedianResponseTime 2 | ALERT APIHighMedianResponseTime 3 | IF histogram_quantile(0.5, sum(rate(http_request_duration_ms_bucket[1m])) by (le, service, route, method)) > 100 4 | FOR 60s 5 | ANNOTATIONS { 6 | summary = "High median response time on {{ $labels.service }} and {{ $labels.method }} {{ $labels.route }}", 7 | description = "{{ $labels.service }}, {{ $labels.method }} {{ $labels.route }} has a median response time above 100ms (current value: {{ $value }}ms)", 8 | } -------------------------------------------------------------------------------- /.docker/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 10s 3 | scrape_timeout: 5s 4 | evaluation_interval: 5s 5 | 6 | scrape_configs: 7 | - job_name: 'node' 8 | static_configs: 9 | - targets: ['host.docker.internal:3000'] 10 | - job_name: 'prometheus' 11 | scrape_interval: 5s 12 | metrics_path: '/api/metrics' 13 | 14 | static_configs: 15 | - targets: ['host.docker.internal:3000'] 16 | labels: 17 | service: 'backend-demo-app' 18 | group: 'backend' 19 | 20 | rule_files: 21 | - 'alert.rules' -------------------------------------------------------------------------------- /.github/workflows/backend-realtime.yaml: -------------------------------------------------------------------------------- 1 | name: backend-realtime 2 | on: push 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Yarn Install 9 | run: yarn --cwd backend-realtime 10 | - name: Run test 11 | run: yarn --cwd backend-realtime test 12 | typescript: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Yarn Install 17 | run: yarn --cwd backend-realtime 18 | - name: TypeScript Build 19 | run: yarn --cwd backend-realtime build 20 | -------------------------------------------------------------------------------- /.github/workflows/backend.yml: -------------------------------------------------------------------------------- 1 | name: backend 2 | on: push 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Yarn Install 9 | run: yarn --cwd backend 10 | - name: Run test 11 | run: yarn --cwd backend test 12 | typescript: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Yarn Install 17 | run: yarn --cwd backend 18 | - name: TypeScript Build 19 | run: yarn --cwd backend build 20 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '20 19 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /.github/workflows/frontend.yml: -------------------------------------------------------------------------------- 1 | name: frontend 2 | on: push 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Yarn Install 9 | run: yarn --cwd frontend 10 | - name: Run Lint 11 | run: yarn --cwd frontend lint 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn-error.log 4 | .eslintcache 5 | .tmp 6 | .public 7 | tsconfig.tsbuildinfo 8 | *.sqlite -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | always-auth=false -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.yml -------------------------------------------------------------------------------- /backend-realtime/.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .github 3 | .vscode 4 | *.sqlite 5 | node_modules 6 | .env 7 | .eslintignore 8 | .eslintrc 9 | .gitignore 10 | .git 11 | .dockerignore 12 | .editorconfig 13 | .prettierignore 14 | .prettierrc 15 | Dockerfile 16 | readme.md 17 | dist 18 | tsconfig.tsbuildinfo 19 | docker-compose.yml -------------------------------------------------------------------------------- /backend-realtime/.env: -------------------------------------------------------------------------------- 1 | # Environment 2 | NODE_ENV=dev 3 | LOG_LEVEL=debug 4 | 5 | # Server 6 | PORT=3001 7 | HOST=localhost 8 | 9 | # Kafka 10 | KAFKA_HOST=localhost 11 | KAFKA_PORT=9092 -------------------------------------------------------------------------------- /backend-realtime/.eslintignore: -------------------------------------------------------------------------------- 1 | # /node_modules/* in the project root is ignored by default 2 | # build artefacts 3 | dist/* 4 | coverage/* 5 | # data definition files 6 | **/*.d.ts 7 | # custom definition files 8 | /src/types/ -------------------------------------------------------------------------------- /backend-realtime/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": ["plugin:@typescript-eslint/recommended", "prettier"], 4 | "parserOptions": { "project": "./tsconfig.json" }, 5 | "plugins": ["@typescript-eslint", "prettier"], 6 | "rules": { 7 | "@typescript-eslint/camelcase": "off", 8 | "@typescript-eslint/explicit-function-return-type": "off", 9 | "@typescript-eslint/explicit-module-boundary-types": "off", 10 | "@typescript-eslint/no-explicit-any": "off", // in some cases, you cannot using anything else than any. It's the reviewer job to challenge if using any is incorrect 11 | "@typescript-eslint/no-unused-vars": "error", 12 | "camelcase": "off", 13 | "import/no-cycle": "off", 14 | "no-underscore-dangle": "off", 15 | "no-unused-vars": "off", // if we use @typescript-eslint/no-unused-vars, we cannot use that one at the same time 16 | "prettier/prettier": "error", 17 | "radix": ["error", "as-needed"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend-realtime/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | always-auth=false -------------------------------------------------------------------------------- /backend-realtime/.prettierignore: -------------------------------------------------------------------------------- 1 | *.yml -------------------------------------------------------------------------------- /backend-realtime/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "es5" 6 | } -------------------------------------------------------------------------------- /backend-realtime/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | COPY package.json yarn.lock .npmrc /app/ 4 | 5 | WORKDIR /app 6 | 7 | ENV PORT=3001 8 | ENV NODE_ENV="dev" 9 | ENV LOCAL_DOCKER="false" 10 | ENV LOG_LEVEL="INFO" 11 | ENV KAFKA_HOST="kafka" 12 | ENV KAFKA_PORT=9092 13 | 14 | RUN yarn install 15 | 16 | COPY . /app 17 | 18 | RUN yarn build 19 | 20 | EXPOSE 3001 21 | 22 | CMD [ "yarn", "start" ] -------------------------------------------------------------------------------- /backend-realtime/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-project-backend-realtime", 3 | "private": true, 4 | "version": "0.0.1", 5 | "license": "UNLICENSED", 6 | "description": "Backend-Realtime of demo-project", 7 | "author": { 8 | "name": "Raed Chammam", 9 | "email": "raed.chammam@gmail.com", 10 | "url": "https://raed.dev" 11 | }, 12 | "scripts": { 13 | "clean": "rimraf ./dist", 14 | "lint": "eslint --ext .js,.ts ./src ", 15 | "lint:fix": "yarn lint --fix", 16 | "lint:ts": "tsc --noEmit", 17 | "test": "yarn lint && yarn lint:ts", 18 | "dev:source": "tsc-watch --noClear --onSuccess \"node --require dotenv/config ./dist/start.js\"", 19 | "dev": "yarn clean && yarn dev:source", 20 | "build": "yarn clean && yarn build:source", 21 | "build:source": "tsc", 22 | "start": "node --require dotenv/config ./dist/start.js", 23 | "docker:build": "docker build --tag raedchammam/demo-project-backend-realtime .", 24 | "docker:run": "docker run -d -p 3000:3000 --name backend-realtime raedchammam/demo-project-backend-realtime", 25 | "docker:stop-container": "docker stop backend-realtime", 26 | "docker:remove-container": "docker rm backend-realtime", 27 | "docker:remove-image": "docker rmi raedchammam/demo-project-backend-realtime" 28 | }, 29 | "devDependencies": { 30 | "@types/body-parser": "^1.19.2", 31 | "@types/command-line-args": "^5.2.0", 32 | "@types/eslint": "^7.2.11", 33 | "@types/express": "^4.17.13", 34 | "@types/method-override": "^0.0.31", 35 | "@types/morgan": "^1.9.3", 36 | "@types/node": "^14.14.10", 37 | "@types/stoppable": "^1.1.1", 38 | "@types/uuid": "^8.3.4", 39 | "@types/ws": "^7.4.4", 40 | "@typescript-eslint/eslint-plugin": "^5.0.0", 41 | "@typescript-eslint/parser": "^5.0.0", 42 | "concurrently": "^6.2.0", 43 | "eslint": "^8.10.0", 44 | "eslint-config-prettier": "^8.5.0", 45 | "eslint-plugin-import": "^2.25.4", 46 | "eslint-plugin-prettier": "^4.0.0", 47 | "nodemon": "^2.0.15", 48 | "prettier": "^2.5.1", 49 | "ts-node": "^10.6.0", 50 | "tsc-watch": "^4.6.0", 51 | "tsconfig-paths": "^3.13.0", 52 | "typescript": "^4.6.2" 53 | }, 54 | "dependencies": { 55 | "body-parser": "^1.19.2", 56 | "chalk": "^4.1.1", 57 | "command-line-args": "^5.2.1", 58 | "dotenv": "^10.0.0", 59 | "express": "^4.17.3", 60 | "express-async-errors": "^3.1.1", 61 | "http-status-codes": "^2.2.0", 62 | "kafkajs": "^1.16.0", 63 | "method-override": "^3.0.0", 64 | "morgan": "^1.10.0", 65 | "pg": "^8.7.3", 66 | "prom-client": "^13.1.0", 67 | "rimraf": "^3.0.2", 68 | "rxjs": "^7.5.4", 69 | "socket.io": "^4.4.1", 70 | "stoppable": "^1.1.0", 71 | "uuid": "^8.3.1", 72 | "winston": "^3.6.0", 73 | "ws": "^7.4.6" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /backend-realtime/src/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response, NextFunction } from 'express' 2 | import prometheus from 'prom-client' 3 | import morgan from 'morgan' 4 | import 'express-async-errors' 5 | 6 | import { httpRequestDurationMicroseconds } from './common/monitoring' 7 | import { environment } from './common/environment' 8 | import { registerRoutes } from './register-routes' 9 | import { normalizePath } from './common/path-normalizer' 10 | 11 | const { NODE_ENV } = environment() 12 | 13 | /** ********************************************************************************** 14 | * Create Express server 15 | ********************************************************************************** */ 16 | const appExpress = express() 17 | 18 | /** ********************************************************************************** 19 | * Set basic express settings 20 | ********************************************************************************** */ 21 | prometheus.collectDefaultMetrics() 22 | 23 | appExpress.use(express.json()) 24 | appExpress.use(express.urlencoded({ extended: true })) 25 | 26 | /** ********************************************************************************** 27 | * Prometheus 28 | ********************************************************************************** */ 29 | 30 | // Used for metrics, runs before each request 31 | appExpress.use((_req: Request, res: Response, next: NextFunction) => { 32 | res.locals.startEpoch = Date.now() 33 | next() 34 | }) 35 | 36 | // Used for metrics, runs after each request 37 | appExpress.use((req: Request, res: Response, next: NextFunction) => { 38 | res.on('finish', () => { 39 | const responseTimeInMs = Date.now() - res.locals.startEpoch 40 | httpRequestDurationMicroseconds 41 | .labels(req.method, normalizePath(req.path), `${res.statusCode}`) 42 | .observe(responseTimeInMs) 43 | }) 44 | next() 45 | }) 46 | 47 | // Show routes called in console during development 48 | if (NODE_ENV === 'dev') { 49 | appExpress.use( 50 | morgan('dev', { 51 | skip: (req: Request) => req.url.includes('metrics'), 52 | }) 53 | ) 54 | } 55 | 56 | appExpress.get('/api/metrics', async (_req: Request, res: Response) => { 57 | res.set('Content-Type', prometheus.register.contentType) 58 | res.end(await prometheus.register.metrics()) 59 | }) 60 | 61 | /** ********************************************************************************** 62 | * Register API routes 63 | ********************************************************************************** */ 64 | registerRoutes(appExpress) 65 | 66 | /** ********************************************************************************** 67 | * Start the Express server 68 | ********************************************************************************** */ 69 | 70 | // Export express instance 71 | export default appExpress 72 | -------------------------------------------------------------------------------- /backend-realtime/src/common/environment.ts: -------------------------------------------------------------------------------- 1 | export interface IEnvironment { 2 | PORT: number 3 | NODE_ENV: NodeEnvironment 4 | KAFKA_HOST: string 5 | KAFKA_PORT: number 6 | LOG_LEVEL: LogLevel 7 | } 8 | 9 | export const validNodeEnvs = ['dev', 'test', 'prod'] as const 10 | export type NodeEnvironment = typeof validNodeEnvs[number] 11 | 12 | const validLogLevels = [ 13 | 'error', 14 | 'warn', 15 | 'help', 16 | 'data', 17 | 'info', 18 | 'debug', 19 | 'prompt', 20 | 'http', 21 | 'verbose', 22 | 'input', 23 | 'silly', 24 | ] as const 25 | 26 | export type LogLevel = typeof validLogLevels[number] 27 | 28 | const getEnvValue = (key: string) => { 29 | const value = process.env[key] 30 | if (!value) { 31 | throw new Error(`Environment variable ${key} not found.`) 32 | } 33 | return value 34 | } 35 | 36 | const getNumericEnvValue = (key: string) => { 37 | const valueStr = getEnvValue(key) 38 | const value = parseInt(valueStr) 39 | if (isNaN(value) || value <= 0) { 40 | throw new Error(`Expected ${key} to be a positive integer, got="{value}"`) 41 | } 42 | return value 43 | } 44 | 45 | const getConstrainedEnvValue = ( 46 | key: string, 47 | values: ReadonlyArray, 48 | defaultValue?: T 49 | ) => { 50 | try { 51 | const value = getEnvValue(key) 52 | if (values.includes(value as T)) { 53 | return value as T 54 | } 55 | throw new Error(`Expected ${key} to be one of ${values.join(',')}`) 56 | } catch (e) { 57 | if (defaultValue) { 58 | return defaultValue 59 | } 60 | throw e 61 | } 62 | } 63 | 64 | export const environment = (): IEnvironment => { 65 | const PORT = getNumericEnvValue('PORT') 66 | const NODE_ENV = getConstrainedEnvValue('NODE_ENV', validNodeEnvs) 67 | const LOG_LEVEL = getConstrainedEnvValue('LOG_LEVEL', validLogLevels, 'info') 68 | 69 | // Kafka 70 | const KAFKA_HOST = getEnvValue('KAFKA_HOST') 71 | const KAFKA_PORT = getNumericEnvValue('KAFKA_PORT') 72 | 73 | return { 74 | PORT, 75 | NODE_ENV, 76 | LOG_LEVEL, 77 | KAFKA_HOST, 78 | KAFKA_PORT, 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /backend-realtime/src/common/errors/operation-error.ts: -------------------------------------------------------------------------------- 1 | export type OperationErrorMessage = 'UNKNOWN_ERROR' 2 | 3 | export class OperationError extends Error { 4 | constructor(message: OperationErrorMessage, readonly status: number) { 5 | super(message) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend-realtime/src/common/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from 'winston' 2 | import { logLevel } from 'kafkajs' 3 | import { environment } from './environment' 4 | 5 | // Import Functions 6 | const { File, Console } = transports 7 | const { LOG_LEVEL } = environment() 8 | 9 | // Init logger 10 | const logger = createLogger({ 11 | level: LOG_LEVEL, 12 | }) 13 | 14 | /** 15 | * For production write to all logs with level `info` and below 16 | * to `combined.log. Write all logs error (and below) to `error.log`. 17 | * For development, print to the console. 18 | */ 19 | if (process.env.NODE_ENV === 'production') { 20 | const fileFormat = format.combine(format.timestamp(), format.json()) 21 | const errTransport = new File({ 22 | filename: './logs/error.log', 23 | format: fileFormat, 24 | level: 'error', 25 | }) 26 | const infoTransport = new File({ 27 | filename: './logs/combined.log', 28 | format: fileFormat, 29 | }) 30 | logger.add(errTransport) 31 | logger.add(infoTransport) 32 | } else { 33 | const errorStackFormat = format((info) => { 34 | if (info.stack) { 35 | // tslint:disable-next-line:no-console 36 | console.log(info.stack) 37 | return false 38 | } 39 | return info 40 | }) 41 | const consoleTransport = new Console({ 42 | format: format.combine(format.colorize(), format.simple(), errorStackFormat()), 43 | }) 44 | logger.add(consoleTransport) 45 | } 46 | 47 | export const kafkaToWinstonLogLevel = (level: logLevel) => { 48 | switch (level) { 49 | case logLevel.ERROR: 50 | case logLevel.NOTHING: 51 | return 'error' 52 | case logLevel.WARN: 53 | return 'warn' 54 | case logLevel.INFO: 55 | return 'info' 56 | case logLevel.DEBUG: 57 | return 'debug' 58 | } 59 | } 60 | 61 | export const LogCreator = () => { 62 | return ({ level, log }: any) => { 63 | const { message, ...extra } = log 64 | logger.log({ 65 | level: kafkaToWinstonLogLevel(level), 66 | message, 67 | extra, 68 | }) 69 | } 70 | } 71 | 72 | export default logger 73 | -------------------------------------------------------------------------------- /backend-realtime/src/common/monitoring/index.ts: -------------------------------------------------------------------------------- 1 | import prometheus from 'prom-client' 2 | 3 | export const httpRequestDurationMicroseconds = new prometheus.Histogram({ 4 | name: 'http_request_duration_ms', 5 | help: 'Duration of HTTP requests in ms', 6 | labelNames: ['method', 'route', 'code'], 7 | buckets: [0.1, 5, 15, 50, 100, 200, 300, 400, 500], // buckets for response time from 0.1ms to 500ms 8 | }) 9 | 10 | export const redisCacheHitCounter = new prometheus.Counter({ 11 | name: 'redis_cache_hit_counter', 12 | help: 'Redis redis cache hit counter', 13 | labelNames: ['counter'], 14 | }) 15 | -------------------------------------------------------------------------------- /backend-realtime/src/common/path-normalizer.ts: -------------------------------------------------------------------------------- 1 | export const normalizePath = (path: string) => { 2 | if (path.endsWith('.js') || path.endsWith('.css')) { 3 | return 'static-resource' 4 | } 5 | 6 | const withoutUUID = path.replace( 7 | /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/, 8 | ':id' 9 | ) 10 | 11 | return withoutUUID 12 | } 13 | -------------------------------------------------------------------------------- /backend-realtime/src/common/timer.ts: -------------------------------------------------------------------------------- 1 | export class Timer { 2 | private readonly startTime = new Date().getTime() 3 | 4 | public elapsed() { 5 | return new Date().getTime() - this.startTime 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend-realtime/src/kafka/index.ts: -------------------------------------------------------------------------------- 1 | import { Kafka } from 'kafkajs' 2 | import { environment } from '../common/environment' 3 | const { KAFKA_HOST, KAFKA_PORT } = environment() 4 | import logger, { LogCreator } from '../common/logger' 5 | 6 | let isConnected = false 7 | 8 | export const kafka = new Kafka({ 9 | clientId: 'demo-app-backend-realtime', 10 | brokers: [`${KAFKA_HOST}:${KAFKA_PORT}`], 11 | logCreator: LogCreator, 12 | }) 13 | 14 | export const consumer = kafka.consumer({ 15 | groupId: 'demo-app-backend-realtime', 16 | allowAutoTopicCreation: true, 17 | readUncommitted: true, 18 | }) 19 | 20 | export const connectKafka = async () => { 21 | return await consumer.connect() 22 | } 23 | 24 | export const disconnectKafka = async () => { 25 | return await consumer.disconnect() 26 | } 27 | 28 | consumer.on('consumer.connect', ({ timestamp }) => { 29 | isConnected = true 30 | logger.debug(`Kafka consumer.connect, timestamp="${timestamp}"`) 31 | }) 32 | 33 | consumer.on('consumer.disconnect', ({ timestamp }) => { 34 | isConnected = false 35 | logger.debug(`Kafka consumer.disconnect, timestamp="${timestamp}"`) 36 | }) 37 | 38 | export const consumeTopic = async (topic: string) => { 39 | if (isConnected) { 40 | await consumer.subscribe({ topic, fromBeginning: true }) 41 | } 42 | return Promise.resolve() 43 | } 44 | -------------------------------------------------------------------------------- /backend-realtime/src/register-routes.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser' 2 | import express from 'express' 3 | import methodOverride from 'method-override' 4 | import { environment } from './common/environment' 5 | import logger from './common/logger' 6 | import { OperationError } from './common/errors/operation-error' 7 | 8 | import { StatusCodes } from 'http-status-codes' 9 | 10 | interface IError { 11 | status?: number 12 | fields?: string[] 13 | message?: string 14 | name?: string 15 | } 16 | 17 | export const registerRoutes = (app: express.Express) => { 18 | app 19 | .use(bodyParser.urlencoded({ extended: true })) 20 | .use(bodyParser.json()) 21 | .use(methodOverride()) 22 | .use((_req, res, next) => { 23 | if (environment().NODE_ENV === 'dev') { 24 | res.header('Access-Control-Allow-Origin', '*') 25 | res.header( 26 | 'Access-Control-Allow-Headers', 27 | 'Origin, X-Requested-With, Content-Type, Accept, Authorization' 28 | ) 29 | } 30 | next() 31 | }) 32 | 33 | app.use((_req, res: express.Response) => { 34 | res.status(StatusCodes.NOT_FOUND).send({ 35 | message: 'Not Found', 36 | }) 37 | }) 38 | 39 | const getErrorBody = (err: unknown) => { 40 | if (err instanceof OperationError) { 41 | return { 42 | message: err.message, 43 | status: err.status, 44 | } 45 | } else { 46 | return { 47 | // @ts-expect-error error could have a message 48 | message: err.message || 'UNKNOWN_ERROR', 49 | // @ts-expect-error error could have a status 50 | status: err.status || StatusCodes.INTERNAL_SERVER_ERROR, 51 | } 52 | } 53 | } 54 | 55 | app.use( 56 | (err: IError, _req: express.Request, res: express.Response, next: express.NextFunction) => { 57 | if (environment().NODE_ENV === 'dev') { 58 | logger.error(err) 59 | } 60 | 61 | const body = getErrorBody(err) 62 | res.status(Number(body.status)).json(body) 63 | next() 64 | } 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /backend-realtime/src/server.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | 3 | import http from 'http' 4 | import { promisify } from 'util' 5 | import stoppable from 'stoppable' 6 | 7 | import appExpress from './app' 8 | import logger from './common/logger' 9 | import { connectKafka } from './kafka' 10 | import { environment } from './common/environment' 11 | import { RegisterSocket } from './socket' 12 | 13 | dotenv.config() 14 | 15 | const TERMINATION_GRACE_PERIOD = 30 16 | 17 | const { PORT, NODE_ENV, LOG_LEVEL } = environment() 18 | 19 | /** ********************************************************************************** 20 | * Create HTTP server 21 | ********************************************************************************** */ 22 | const httpServer = http.createServer(appExpress) 23 | const app = stoppable(httpServer, TERMINATION_GRACE_PERIOD) 24 | 25 | app.on('listening', async () => { 26 | logger.info(`Server started on PORT="${PORT}", NODE_ENV="${NODE_ENV}", LOG_LEVEL=${LOG_LEVEL}`) 27 | 28 | await connectKafka() 29 | 30 | if (NODE_ENV === 'dev') { 31 | logger.info(`Open: ws://localhost:${PORT}`) 32 | } 33 | }) 34 | 35 | app.on('error', (error: any) => { 36 | if (error.syscall !== 'listen') { 37 | throw error 38 | } 39 | const bind = typeof PORT === 'string' ? `Pipe ${PORT}` : `Port ${PORT}` 40 | 41 | switch (error.code) { 42 | case 'EACCES': 43 | logger.error(`Port: ${bind} requires elevated privileges`) 44 | process.exit(1) 45 | case 'EADDRINUSE': 46 | logger.error(`$Port: ${bind} is already in use`) 47 | process.exit(1) 48 | default: 49 | throw error 50 | } 51 | }) 52 | 53 | RegisterSocket(httpServer) 54 | 55 | const handleSignal = (signal: string) => async () => { 56 | try { 57 | logger.warn(`Got ${signal}. Graceful shutdown start`, new Date().toISOString()) 58 | const stopAppServer = promisify(app.stop) 59 | await Promise.all([stopAppServer()]) 60 | logger.warn('Successful graceful shutdown', new Date().toISOString()) 61 | process.exit(0) 62 | } catch (error) { 63 | logger.warn('Error during graceful shutdown', error) 64 | process.exit(1) 65 | } 66 | } 67 | 68 | process.on('SIGTERM', handleSignal('SIGTERM')) 69 | process.on('SIGINT', handleSignal('SIGINT')) 70 | 71 | // Export http server 72 | export const server = async () => { 73 | app.listen(PORT) 74 | } 75 | -------------------------------------------------------------------------------- /backend-realtime/src/socket.ts: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import WebSocket from 'ws' 3 | import { consumer } from './kafka' 4 | import { EachMessagePayload } from 'kafkajs' 5 | import logger from './common/logger' 6 | import { Subject } from 'rxjs' 7 | 8 | export const subject = new Subject() 9 | 10 | export const RegisterSocket = async (server: http.Server) => { 11 | // Kafka 12 | await consumer.subscribe({ topic: 'todo-created', fromBeginning: false }) 13 | consumer.run({ 14 | eachMessage: async ({ message, topic }: EachMessagePayload) => { 15 | try { 16 | if (topic === 'todo-created' && message.value) { 17 | subject.next({ 18 | todo: JSON.parse(message.value.toString()), 19 | timestamp: message.timestamp, 20 | }) 21 | } 22 | } catch (error) { 23 | logger.warn(`Consumer error: ${error}`) 24 | } 25 | }, 26 | }) 27 | 28 | // WS 29 | const wss = new WebSocket.Server({ server, noServer: true }) 30 | 31 | wss.on('connection', (ws) => { 32 | logger.debug('WS connection') 33 | 34 | const subscription = subject.subscribe((update) => { 35 | const payload = JSON.stringify(update) 36 | logger.debug(`WS send "${payload}"`) 37 | ws.send(JSON.stringify(payload)) 38 | }) 39 | 40 | ws.on('message', (message) => { 41 | logger.debug(`WS received "${message}"`) 42 | }) 43 | 44 | ws.on('close', () => { 45 | subscription.unsubscribe() 46 | logger.debug('WS close-connection') 47 | }) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /backend-realtime/src/start.ts: -------------------------------------------------------------------------------- 1 | import { server } from './server' 2 | 3 | server() 4 | -------------------------------------------------------------------------------- /backend-realtime/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "outDir": "dist", 5 | "rootDir": "src", 6 | "importHelpers": true, 7 | "module": "commonjs", 8 | "strict": true, 9 | "resolveJsonModule": true, 10 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true, 12 | "esModuleInterop": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "downlevelIteration": true, 16 | "moduleResolution": "node", 17 | "target": "es5", 18 | "lib": ["es6"], 19 | "removeComments": true, 20 | "composite": false, 21 | "incremental": true, 22 | "declaration": false, 23 | "declarationMap": false, 24 | "types": ["node"], 25 | "typeRoots": ["node_modules/@types"], 26 | "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" 27 | }, 28 | "include": ["./src", "./src/index.html"], 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .github 3 | .vscode 4 | *.sqlite 5 | node_modules 6 | .env 7 | .eslintignore 8 | .eslintrc 9 | .gitignore 10 | .git 11 | .dockerignore 12 | .editorconfig 13 | .prettierignore 14 | .prettierrc 15 | Dockerfile 16 | readme.md 17 | dist 18 | tsconfig.tsbuildinfo 19 | docker-compose.yml -------------------------------------------------------------------------------- /backend/.env: -------------------------------------------------------------------------------- 1 | # Environment 2 | NODE_ENV=dev 3 | LOCAL_DOCKER=false 4 | LOG_LEVEL=debug 5 | 6 | # Server 7 | PORT=3000 8 | HOST=localhost 9 | 10 | # Database 11 | PG_HOST=localhost 12 | PG_PORT=5432 13 | PG_USERNAME=postgres 14 | PG_PASSWORD=changeme 15 | 16 | # Redis 17 | REDIS_HOST=localhost 18 | REDIS_PORT=6379 19 | 20 | # Kafka 21 | KAFKA_HOST=localhost 22 | KAFKA_PORT=9092 -------------------------------------------------------------------------------- /backend/.eslintignore: -------------------------------------------------------------------------------- 1 | # /node_modules/* in the project root is ignored by default 2 | # build artefacts 3 | node_modules 4 | dist/* 5 | coverage/* 6 | # data definition files 7 | **/*.d.ts 8 | # custom definition files 9 | /src/types/ -------------------------------------------------------------------------------- /backend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": ["plugin:@typescript-eslint/recommended", "prettier"], 4 | "parserOptions": { "project": "./tsconfig.json" }, 5 | "plugins": ["@typescript-eslint", "prettier"], 6 | "rules": { 7 | "@typescript-eslint/camelcase": "off", 8 | "@typescript-eslint/explicit-function-return-type": "off", 9 | "@typescript-eslint/explicit-module-boundary-types": "off", 10 | "@typescript-eslint/no-explicit-any": "off", // in some cases, you cannot using anything else than any. It's the reviewer job to challenge if using any is incorrect 11 | "@typescript-eslint/no-unused-vars": "error", 12 | "camelcase": "off", 13 | "import/no-cycle": "off", 14 | "no-underscore-dangle": "off", 15 | "no-unused-vars": "off", // if we use @typescript-eslint/no-unused-vars, we cannot use that one at the same time 16 | "prettier/prettier": "error", 17 | "radix": ["error", "as-needed"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | always-auth=false -------------------------------------------------------------------------------- /backend/.prettierignore: -------------------------------------------------------------------------------- 1 | *.yml -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "es5" 6 | } -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | COPY package.json yarn.lock .npmrc /app/ 4 | 5 | WORKDIR /app 6 | 7 | ENV PORT=3000 8 | ENV NODE_ENV="dev" 9 | ENV LOCAL_DOCKER="false" 10 | ENV LOG_LEVEL="INFO" 11 | ENV PG_HOST="postgres_db" 12 | ENV PG_PORT=5432 13 | ENV PG_USERNAME="postgres" 14 | ENV PG_PASSWORD="changeme" 15 | ENV REDIS_HOST="redis_cache" 16 | ENV REDIS_PORT=6379 17 | ENV KAFKA_HOST="kafka" 18 | ENV KAFKA_PORT=9092 19 | 20 | RUN yarn install 21 | 22 | COPY . /app 23 | 24 | RUN yarn build 25 | 26 | EXPOSE 3000 27 | 28 | CMD [ "yarn", "start" ] -------------------------------------------------------------------------------- /backend/data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raed667/demo-project/7163df45108ec3142b19c1373db42afd8ba6fcf6/backend/data/.gitkeep -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-project-backend", 3 | "private": true, 4 | "version": "0.0.1", 5 | "license": "UNLICENSED", 6 | "description": "Backend of demo-project", 7 | "author": { 8 | "name": "Raed Chammam", 9 | "email": "raed.chammam@gmail.com", 10 | "url": "https://raed.dev" 11 | }, 12 | "scripts": { 13 | "clean": "rimraf ./dist", 14 | "lint": "eslint --ext .js,.ts ./src ", 15 | "lint:fix": "yarn lint --fix", 16 | "lint:ts": "tsc --noEmit", 17 | "test": "yarn lint && yarn lint:ts", 18 | "dev:source": "tsc-watch --noClear --onSuccess \"node --require dotenv/config ./dist/start.js\"", 19 | "dev:routes": "nodemon --watch ./src/controller -e ts -x \"yarn build:routes\"", 20 | "dev": "yarn clean && concurrently \"yarn dev:source\" \"yarn dev:routes\"", 21 | "build:routes": "tsoa spec-and-routes", 22 | "build:source": "tsc", 23 | "build": "yarn clean && yarn build:routes && yarn build:source", 24 | "start": "node --require dotenv/config ./dist/start.js", 25 | "docker:build": "docker build --tag raedchammam/demo-project-backend .", 26 | "docker:run": "docker run -d -p 3000:3000 --name backend raedchammam/demo-project-backend", 27 | "docker:stop-container": "docker stop backend", 28 | "docker:remove-container": "docker rm backend", 29 | "docker:remove-image": "docker rmi raedchammam/demo-project-backend" 30 | }, 31 | "devDependencies": { 32 | "@types/body-parser": "^1.19.2", 33 | "@types/command-line-args": "^5.0.0", 34 | "@types/eslint": "^7.2.11", 35 | "@types/express": "^4.17.13", 36 | "@types/ioredis": "^4.26.4", 37 | "@types/method-override": "^0.0.31", 38 | "@types/morgan": "^1.9.3", 39 | "@types/node": "^14.14.10", 40 | "@types/stoppable": "^1.1.1", 41 | "@types/swagger-ui-express": "^4.1.3", 42 | "@types/uuid": "^8.3.4", 43 | "@typescript-eslint/eslint-plugin": "^5.0.0", 44 | "@typescript-eslint/parser": "^5.0.0", 45 | "eslint": "^8.10.0", 46 | "concurrently": "^6.2.0", 47 | "eslint-config-prettier": "^8.5.0", 48 | "eslint-plugin-import": "^2.25.4", 49 | "eslint-plugin-prettier": "^4.0.0", 50 | "nodemon": "^2.0.15", 51 | "prettier": "^2.5.1", 52 | "ts-node": "^10.6.0", 53 | "tsc-watch": "^4.6.0", 54 | "tsconfig-paths": "^3.13.0", 55 | "typescript": "^4.6.2" 56 | }, 57 | "dependencies": { 58 | "body-parser": "^1.19.2", 59 | "chalk": "^4.1.1", 60 | "command-line-args": "^5.2.1", 61 | "dotenv": "^10.0.0", 62 | "express": "^4.17.3", 63 | "express-async-errors": "^3.1.1", 64 | "http-status-codes": "^2.2.0", 65 | "ioredis": "^4.28.5", 66 | "kafkajs": "^1.16.0", 67 | "method-override": "^3.0.0", 68 | "morgan": "^1.10.0", 69 | "pg": "^8.7.3", 70 | "prom-client": "^13.1.0", 71 | "rimraf": "^3.0.2", 72 | "sqlite3": "^5.0.2", 73 | "stoppable": "^1.1.0", 74 | "swagger-ui-express": "^4.3.0", 75 | "tsoa": "^3.14.1", 76 | "typeorm": "^0.2.45", 77 | "uuid": "^8.3.1", 78 | "winston": "^3.6.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /backend/src/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response, NextFunction } from 'express' 2 | import prometheus from 'prom-client' 3 | import morgan from 'morgan' 4 | import swaggerUi from 'swagger-ui-express' 5 | import 'express-async-errors' 6 | 7 | import { httpRequestDurationMicroseconds } from './common/monitoring' 8 | import { environment } from './common/environment' 9 | import { registerRoutes } from './register-routes' 10 | import { normalizePath } from './common/path-normalizer' 11 | 12 | const { NODE_ENV } = environment() 13 | 14 | /** ********************************************************************************** 15 | * Create Express server 16 | ********************************************************************************** */ 17 | const appExpress = express() 18 | 19 | /** ********************************************************************************** 20 | * Set basic express settings 21 | ********************************************************************************** */ 22 | prometheus.collectDefaultMetrics() 23 | 24 | appExpress.use(express.json()) 25 | appExpress.use(express.urlencoded({ extended: true })) 26 | 27 | /** ********************************************************************************** 28 | * Prometheus 29 | ********************************************************************************** */ 30 | 31 | // Used for metrics, runs before each request 32 | appExpress.use((_req: Request, res: Response, next: NextFunction) => { 33 | res.locals.startEpoch = Date.now() 34 | next() 35 | }) 36 | 37 | // Used for metrics, runs after each request 38 | appExpress.use((req: Request, res: Response, next: NextFunction) => { 39 | res.on('finish', () => { 40 | const responseTimeInMs = Date.now() - res.locals.startEpoch 41 | httpRequestDurationMicroseconds 42 | .labels(req.method, normalizePath(req.path), `${res.statusCode}`) 43 | .observe(responseTimeInMs) 44 | }) 45 | next() 46 | }) 47 | 48 | // Show routes called in console during development 49 | if (NODE_ENV === 'dev') { 50 | appExpress.use( 51 | morgan('dev', { 52 | skip: (req: Request) => req.url.includes('metrics'), 53 | }) 54 | ) 55 | } 56 | 57 | appExpress.use('/api/docs', swaggerUi.serve, async (_req: Request, res: Response) => { 58 | return res.send(swaggerUi.generateHTML(await import('./swagger.json'))) 59 | }) 60 | 61 | appExpress.get('/api/metrics', async (_req: Request, res: Response) => { 62 | res.set('Content-Type', prometheus.register.contentType) 63 | res.end(await prometheus.register.metrics()) 64 | }) 65 | 66 | /** ********************************************************************************** 67 | * Register API routes 68 | ********************************************************************************** */ 69 | registerRoutes(appExpress) 70 | 71 | /** ********************************************************************************** 72 | * Start the Express server 73 | ********************************************************************************** */ 74 | 75 | // Export express instance 76 | export default appExpress 77 | -------------------------------------------------------------------------------- /backend/src/common/environment.ts: -------------------------------------------------------------------------------- 1 | export interface IEnvironment { 2 | PORT: number 3 | NODE_ENV: NodeEnvironment 4 | PG_HOST: string 5 | PG_PORT: number 6 | PG_PASSWORD: string 7 | PG_USERNAME: string 8 | REDIS_HOST: string 9 | REDIS_PORT: number 10 | LOCAL_DOCKER: boolean 11 | KAFKA_HOST: string 12 | KAFKA_PORT: number 13 | LOG_LEVEL: LogLevel 14 | } 15 | 16 | export const validNodeEnvs = ['dev', 'test', 'prod'] as const 17 | export type NodeEnvironment = typeof validNodeEnvs[number] 18 | 19 | const validLogLevels = [ 20 | 'error', 21 | 'warn', 22 | 'help', 23 | 'data', 24 | 'info', 25 | 'debug', 26 | 'prompt', 27 | 'http', 28 | 'verbose', 29 | 'input', 30 | 'silly', 31 | ] as const 32 | 33 | export type LogLevel = typeof validLogLevels[number] 34 | 35 | const getEnvValue = (key: string) => { 36 | const value = process.env[key] 37 | if (!value) { 38 | throw new Error(`Environment variable ${key} not found.`) 39 | } 40 | return value 41 | } 42 | 43 | const getNumericEnvValue = (key: string) => { 44 | const valueStr = getEnvValue(key) 45 | const value = parseInt(valueStr) 46 | if (isNaN(value) || value <= 0) { 47 | throw new Error(`Expected ${key} to be a positive integer, got="{value}"`) 48 | } 49 | return value 50 | } 51 | 52 | const getBooleanEnvValue = (key: string) => { 53 | const valueStr = getEnvValue(key) 54 | return valueStr === 'true' 55 | } 56 | 57 | const getConstrainedEnvValue = ( 58 | key: string, 59 | values: ReadonlyArray, 60 | defaultValue?: T 61 | ) => { 62 | try { 63 | const value = getEnvValue(key) 64 | if (values.includes(value as T)) { 65 | return value as T 66 | } 67 | throw new Error(`Expected ${key} to be one of ${values.join(',')}`) 68 | } catch (e) { 69 | if (defaultValue) { 70 | return defaultValue 71 | } 72 | throw e 73 | } 74 | } 75 | 76 | export const environment = (): IEnvironment => { 77 | const PORT = getNumericEnvValue('PORT') 78 | const NODE_ENV = getConstrainedEnvValue('NODE_ENV', validNodeEnvs) 79 | // eslint-disable-next-line @typescript-eslint/naming-convention 80 | const LOCAL_DOCKER = getBooleanEnvValue('LOCAL_DOCKER') 81 | const LOG_LEVEL = getConstrainedEnvValue('LOG_LEVEL', validLogLevels, 'info') 82 | // postgres 83 | const PG_PORT = getNumericEnvValue('PG_PORT') 84 | const PG_HOST = getEnvValue('PG_HOST') 85 | const PG_PASSWORD = getEnvValue('PG_PASSWORD') 86 | const PG_USERNAME = getEnvValue('PG_USERNAME') 87 | // Redis 88 | const REDIS_HOST = getEnvValue('REDIS_HOST') 89 | const REDIS_PORT = getNumericEnvValue('REDIS_PORT') 90 | // Kafka 91 | const KAFKA_HOST = getEnvValue('KAFKA_HOST') 92 | const KAFKA_PORT = getNumericEnvValue('KAFKA_PORT') 93 | 94 | return { 95 | PORT, 96 | NODE_ENV, 97 | PG_PORT, 98 | PG_HOST, 99 | PG_PASSWORD, 100 | PG_USERNAME, 101 | REDIS_HOST, 102 | REDIS_PORT, 103 | LOCAL_DOCKER, 104 | LOG_LEVEL, 105 | KAFKA_HOST, 106 | KAFKA_PORT, 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /backend/src/common/errors/db-error.ts: -------------------------------------------------------------------------------- 1 | import { QueryFailedError } from 'typeorm' 2 | 3 | export class DBError extends Error { 4 | public readonly code: string 5 | 6 | constructor(message: string, public readonly queryError: QueryFailedError) { 7 | super(message) 8 | this.code = (queryError as unknown as { code: string }).code 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/common/errors/operation-error.ts: -------------------------------------------------------------------------------- 1 | export type OperationErrorMessage = 'UNKNOWN_ERROR' | 'DUPLICATE' | 'NOT_FOUND' 2 | 3 | export class OperationError extends Error { 4 | constructor(message: OperationErrorMessage, readonly status: number) { 5 | super(message) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/common/logger.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from 'winston' 2 | import { logLevel } from 'kafkajs' 3 | import { environment } from './environment' 4 | // Import Functions 5 | const { File, Console } = transports 6 | const { LOG_LEVEL } = environment() 7 | 8 | // Init logger 9 | const logger = createLogger({ 10 | level: LOG_LEVEL, 11 | }) 12 | 13 | /** 14 | * For production write to all logs with level `info` and below 15 | * to `combined.log. Write all logs error (and below) to `error.log`. 16 | * For development, print to the console. 17 | */ 18 | if (process.env.NODE_ENV === 'production') { 19 | const fileFormat = format.combine(format.timestamp(), format.json()) 20 | const errTransport = new File({ 21 | filename: './logs/error.log', 22 | format: fileFormat, 23 | level: 'error', 24 | }) 25 | const infoTransport = new File({ 26 | filename: './logs/combined.log', 27 | format: fileFormat, 28 | }) 29 | logger.add(errTransport) 30 | logger.add(infoTransport) 31 | } else { 32 | const errorStackFormat = format((info) => { 33 | if (info.stack) { 34 | // tslint:disable-next-line:no-console 35 | console.log(info.stack) 36 | return false 37 | } 38 | return info 39 | }) 40 | const consoleTransport = new Console({ 41 | format: format.combine(format.colorize(), format.simple(), errorStackFormat()), 42 | }) 43 | logger.add(consoleTransport) 44 | } 45 | 46 | export const kafkaToWinstonLogLevel = (level: logLevel) => { 47 | switch (level) { 48 | case logLevel.ERROR: 49 | case logLevel.NOTHING: 50 | return 'error' 51 | case logLevel.WARN: 52 | return 'warn' 53 | case logLevel.INFO: 54 | return 'info' 55 | case logLevel.DEBUG: 56 | return 'debug' 57 | } 58 | } 59 | 60 | export const LogCreator = () => { 61 | return ({ level, log }: any) => { 62 | const { message, ...extra } = log 63 | logger.log({ 64 | level: kafkaToWinstonLogLevel(level), 65 | message, 66 | extra, 67 | }) 68 | } 69 | } 70 | 71 | export default logger 72 | -------------------------------------------------------------------------------- /backend/src/common/monitoring/index.ts: -------------------------------------------------------------------------------- 1 | import prometheus from 'prom-client' 2 | 3 | export const httpRequestDurationMicroseconds = new prometheus.Histogram({ 4 | name: 'http_request_duration_ms', 5 | help: 'Duration of HTTP requests in ms', 6 | labelNames: ['method', 'route', 'code'], 7 | buckets: [0.1, 5, 15, 50, 100, 200, 300, 400, 500], // buckets for response time from 0.1ms to 500ms 8 | }) 9 | 10 | export const redisCacheHitCounter = new prometheus.Counter({ 11 | name: 'redis_cache_hit_counter', 12 | help: 'Redis redis cache hit counter', 13 | labelNames: ['counter'], 14 | }) 15 | -------------------------------------------------------------------------------- /backend/src/common/path-normalizer.ts: -------------------------------------------------------------------------------- 1 | export const normalizePath = (path: string) => { 2 | if (path.endsWith('.js') || path.endsWith('.css')) { 3 | return 'static-resource' 4 | } 5 | 6 | const withoutUUID = path.replace( 7 | /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/, 8 | ':id' 9 | ) 10 | 11 | return withoutUUID 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/common/timer.ts: -------------------------------------------------------------------------------- 1 | export class Timer { 2 | private readonly startTime = new Date().getTime() 3 | 4 | public elapsed() { 5 | return new Date().getTime() - this.startTime 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/controller/todo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Query, 5 | Get, 6 | Post, 7 | Path, 8 | Route, 9 | SuccessResponse, 10 | Tags, 11 | Delete, 12 | } from 'tsoa' 13 | import { ITodo, Todo } from '../repository/model/todo' 14 | import { TodoService, TodoCreationParams, IUpdateTodoRequest } from '../service/todo' 15 | 16 | @Route('todos') 17 | @Tags('Todo') 18 | export class TodoController extends Controller { 19 | @Get('{id}') 20 | public async getById(@Path() id: string): Promise { 21 | return await this.service.getById(id) 22 | } 23 | 24 | @Get() 25 | public async get(@Query() page = 1, @Query() pageSize = 10): Promise> { 26 | return await this.service.get({ page, pageSize }) 27 | } 28 | 29 | @Post() 30 | @SuccessResponse('201', 'Created') 31 | public async create(@Body() requestBody: TodoCreationParams): Promise { 32 | this.setStatus(201) 33 | return await this.service.create(requestBody) 34 | } 35 | 36 | @Post('{id}') 37 | public async updateStatus( 38 | @Path() id: string, 39 | @Body() requestBody: IUpdateTodoRequest 40 | ): Promise { 41 | return await this.service.update(id, requestBody) 42 | } 43 | 44 | @Delete('{id}') 45 | public async delete(@Path() id: string) { 46 | return await this.service.delete(id) 47 | } 48 | 49 | private get service() { 50 | return new TodoService() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /backend/src/kafka/index.ts: -------------------------------------------------------------------------------- 1 | import { Kafka } from 'kafkajs' 2 | import { environment } from '../common/environment' 3 | const { KAFKA_HOST, KAFKA_PORT } = environment() 4 | import logger, { LogCreator } from '../common/logger' 5 | 6 | let isConnected = false 7 | 8 | export const kafka = new Kafka({ 9 | clientId: 'demo-app-backend', 10 | brokers: [`${KAFKA_HOST}:${KAFKA_PORT}`], 11 | logCreator: LogCreator, 12 | }) 13 | 14 | export const producer = kafka.producer({ idempotent: true }) 15 | 16 | export const connectKafka = async () => { 17 | return await producer.connect() 18 | } 19 | 20 | export const disconnectKafka = async () => { 21 | return await producer.disconnect() 22 | } 23 | 24 | producer.on('producer.connect', ({ timestamp }) => { 25 | isConnected = true 26 | logger.debug(`Kafka producer.connect, timestamp="${timestamp}"`) 27 | }) 28 | 29 | producer.on('producer.disconnect', ({ timestamp }) => { 30 | isConnected = false 31 | logger.debug(`Kafka producer.disconnect, timestamp="${timestamp}"`) 32 | }) 33 | 34 | export const produce = async (topic: string, key: string, value: string) => { 35 | if (isConnected) { 36 | return await producer.send({ 37 | topic, 38 | messages: [{ key, value }], 39 | }) 40 | } 41 | return Promise.resolve() 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/redis/index.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis' 2 | import { environment } from '../common/environment' 3 | import logger from '../common/logger' 4 | 5 | const { NODE_ENV, REDIS_HOST, REDIS_PORT } = environment() 6 | 7 | const clients: { [key: string]: Redis.Redis | undefined } = {} 8 | const errorCountArray: { [key: string]: number | undefined } = {} 9 | 10 | export const getRedisClient = (key = NODE_ENV): Redis.Redis => { 11 | let client = clients[key] 12 | if (!client) { 13 | client = clients[key] = new Redis(REDIS_PORT, REDIS_HOST, { 14 | reconnectOnError: () => true, 15 | connectTimeout: 1000, 16 | lazyConnect: true, 17 | showFriendlyErrorStack: true, 18 | }) 19 | 20 | client.on('connect', () => { 21 | logger.debug('Redis Ready') 22 | }) 23 | 24 | client.on('error', (e) => { 25 | logger.error(e) 26 | let errorCount = errorCountArray[key] 27 | 28 | if (!errorCount) { 29 | errorCount = errorCountArray[key] = 1 30 | } else { 31 | // @ts-expect-error i know what i'm doing 32 | errorCountArray[key] += 1 33 | } 34 | 35 | if (errorCount > 10) { 36 | logger.warn('Redis tried 10 times disconnecting...') 37 | client?.disconnect() 38 | } 39 | }) 40 | } 41 | 42 | client.setMaxListeners(100) 43 | 44 | return client 45 | } 46 | -------------------------------------------------------------------------------- /backend/src/register-routes.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser' 2 | import express from 'express' 3 | import methodOverride from 'method-override' 4 | import { ValidateError } from 'tsoa' 5 | import { environment } from './common/environment' 6 | import logger from './common/logger' 7 | import { RegisterRoutes } from './routes' 8 | import { OperationError } from './common/errors/operation-error' 9 | 10 | import { StatusCodes } from 'http-status-codes' 11 | 12 | interface IError { 13 | status?: number 14 | fields?: string[] 15 | message?: string 16 | name?: string 17 | } 18 | 19 | export const registerRoutes = (app: express.Express) => { 20 | app 21 | .use(bodyParser.urlencoded({ extended: true })) 22 | .use(bodyParser.json()) 23 | .use(methodOverride()) 24 | .use((_req, res, next) => { 25 | if (environment().NODE_ENV === 'dev') { 26 | res.header('Access-Control-Allow-Origin', '*') 27 | res.header( 28 | 'Access-Control-Allow-Headers', 29 | 'Origin, X-Requested-With, Content-Type, Accept, Authorization' 30 | ) 31 | } 32 | next() 33 | }) 34 | 35 | RegisterRoutes(app) 36 | 37 | app.use((_req, res: express.Response) => { 38 | res.status(StatusCodes.NOT_FOUND).send({ 39 | message: 'Not Found', 40 | }) 41 | }) 42 | 43 | const getErrorBody = (err: unknown) => { 44 | if (err instanceof ValidateError) { 45 | return { 46 | message: err.message, 47 | status: StatusCodes.BAD_REQUEST, 48 | fields: err.fields, 49 | name: err.name, 50 | } 51 | } else if (err instanceof OperationError) { 52 | return { 53 | message: err.message, 54 | status: err.status, 55 | } 56 | } else { 57 | return { 58 | // @ts-expect-error error could have a message 59 | message: err.message || 'UNKNOWN_ERROR', 60 | // @ts-expect-error error could have a status 61 | status: err.status || StatusCodes.INTERNAL_SERVER_ERROR, 62 | } 63 | } 64 | } 65 | 66 | app.use( 67 | (err: IError, _req: express.Request, res: express.Response, next: express.NextFunction) => { 68 | if (environment().NODE_ENV === 'dev') { 69 | logger.error(err) 70 | } 71 | 72 | const body = getErrorBody(err) 73 | res.status(Number(body.status)).json(body) 74 | next() 75 | } 76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /backend/src/repository/base.ts: -------------------------------------------------------------------------------- 1 | import { FindConditions, FindManyOptions, FindOneOptions, Repository, DeepPartial } from 'typeorm' 2 | import { DBError } from '../common/errors/db-error' 3 | import { IBaseModel, BaseModel } from './model/base' 4 | import { getDbConnection } from './database' 5 | import { getRedisClient } from '../redis' 6 | import logger from '../common/logger' 7 | import { redisCacheHitCounter } from '../common/monitoring' 8 | 9 | /** 10 | * The generic type arguments for BaseRepository seem a little convoluted, 11 | * but there's a strategy in mind. TypeORM uses classes and class decorators 12 | * to set up establish ORM models and model relations. 13 | * 14 | * By only accepting and producing the interface version of models, we can keep 15 | * the class models from propagating throughout the app and allows repositories 16 | * to run on pure data structures 17 | */ 18 | export abstract class BaseRepository< 19 | // Properties in an existing record 20 | Props extends IBaseModel, 21 | // Class representing TypeORM model 22 | Class extends BaseModel & Props 23 | > { 24 | constructor(private readonly classFn: new () => Class) {} 25 | 26 | public async findOne(options: FindOneOptions): Promise { 27 | try { 28 | // @ts-expect-error we can have an id in the where clause 29 | const id = options?.where?.id || null 30 | if (id != null) { 31 | const cached = await this.getFromCache(id) 32 | if (cached) return cached 33 | } 34 | } catch (error) { 35 | logger.warn(`Unable to reach Redis, error=${error}`) 36 | } 37 | const result = await this.execute((repo) => repo.findOne(options)) 38 | if (result) { 39 | this.addToCache(result) 40 | } 41 | return result 42 | } 43 | 44 | public async find(options: FindManyOptions): Promise { 45 | const results = await this.execute((repo) => repo.find(options)) 46 | results?.forEach((result) => this.addToCache(result)) 47 | return results 48 | } 49 | 50 | public async create(model: DeepPartial) { 51 | const now = new Date() 52 | 53 | const result = await this.execute((repo) => 54 | repo.save({ 55 | ...model, 56 | date_created: now, 57 | date_updated: now, 58 | }) 59 | ) 60 | 61 | this.addToCache(result) 62 | return result 63 | } 64 | 65 | public async update(model: DeepPartial) { 66 | const result = await this.execute((repo) => 67 | repo.save({ 68 | ...model, 69 | date_updated: new Date(), 70 | }) 71 | ) 72 | this.addToCache(result) 73 | return result 74 | } 75 | 76 | public async delete(options: FindConditions): Promise { 77 | await this.execute((repo) => repo.delete(options)) 78 | if (options.id) { 79 | this.removeCache(`${options.id}`) 80 | } 81 | return 82 | } 83 | 84 | private async execute

(fn: (repo: Repository) => Promise

) { 85 | try { 86 | const repo = await this.getRepository() 87 | return await fn(repo) 88 | } catch (err: any) { 89 | throw new DBError(err.message, err) 90 | } 91 | } 92 | 93 | private async getRepository(): Promise> { 94 | const connection = await getDbConnection() 95 | return connection.getRepository(this.classFn) 96 | } 97 | 98 | private addToCache(result: any) { 99 | const redis = this.cache 100 | if (redis) { 101 | redis.set( 102 | `${this.classFn.name}-${result?.id}`, 103 | JSON.stringify(result), 104 | 'EX', 105 | 60 * 5 // Expire after 5 minutes 106 | ) 107 | } 108 | } 109 | private async getFromCache(id: string): Promise { 110 | const redis = this.cache 111 | if (redis) { 112 | const cached = await redis.get(`${this.classFn.name}-${id}`) 113 | if (cached) { 114 | redisCacheHitCounter.inc({ counter: 1 }) 115 | // We have to trust that the redis cache is properly formatted 116 | return JSON.parse(cached) as Props 117 | } 118 | } 119 | return null 120 | } 121 | 122 | private removeCache(id: string) { 123 | const redis = this.cache 124 | if (redis) { 125 | redis.del(`${this.classFn.name}-${id}`) 126 | } 127 | } 128 | 129 | private get cache() { 130 | const redis = getRedisClient() 131 | return redis.status === 'ready' ? redis : null 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /backend/src/repository/database.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { environment } from '../common/environment' 3 | import { ConnectionOptions, Connection, createConnection, getConnection } from 'typeorm' 4 | import { Todo } from './model/todo' 5 | import logger from '../common/logger' 6 | 7 | export const root: string = path.resolve(__dirname, '../../') 8 | const { NODE_ENV, PG_HOST, PG_PASSWORD, PG_PORT, PG_USERNAME, LOCAL_DOCKER } = environment() 9 | 10 | const createSQLiteConnection = async (): Promise => { 11 | const options: ConnectionOptions = { 12 | name: 'dev', 13 | type: 'sqlite', 14 | database: `${root}/data/dev_db.sqlite`, 15 | entities: [Todo], 16 | logging: ['warn', 'error'], 17 | synchronize: false, 18 | dropSchema: false, 19 | migrationsRun: true, 20 | migrations: [path.join(path.resolve(__dirname), '/migrations/*{.ts,.js}')], 21 | cli: { 22 | migrationsDir: 'src/repository/migrations', 23 | }, 24 | } 25 | 26 | const connection = await createConnection(options) 27 | return connection 28 | } 29 | 30 | export const createPGConnection = async (): Promise => { 31 | const options: ConnectionOptions = { 32 | name: 'prod', 33 | type: 'postgres', 34 | host: PG_HOST, 35 | port: PG_PORT, 36 | username: PG_USERNAME, 37 | password: PG_PASSWORD, 38 | entities: [Todo], 39 | logging: ['warn', 'error'], 40 | synchronize: false, 41 | dropSchema: false, 42 | migrationsRun: true, 43 | migrations: [path.join(path.resolve(__dirname), '/migrations/*{.ts,.js}')], 44 | cli: { 45 | migrationsDir: 'src/repository/migrations', 46 | }, 47 | } 48 | 49 | const connection = await createConnection(options) 50 | return connection 51 | } 52 | 53 | export const createDBConnection = async (): Promise => { 54 | if (NODE_ENV === 'prod' || LOCAL_DOCKER) { 55 | logger.debug(`Connecting to PostgreSQL, NODE_ENV=${NODE_ENV}, LOCAL_DOCKER=${LOCAL_DOCKER}`) 56 | return await createPGConnection() 57 | } 58 | logger.debug(`Connecting to SQLite, NODE_ENV=${NODE_ENV}, LOCAL_DOCKER=${LOCAL_DOCKER}`) 59 | return await createSQLiteConnection() 60 | } 61 | 62 | export const getDbConnection = async (): Promise => { 63 | try { 64 | const connectionName = LOCAL_DOCKER ? 'prod' : NODE_ENV 65 | return getConnection(connectionName) 66 | } catch { 67 | return await createDBConnection() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /backend/src/repository/migrations/1606672821869-insert-users.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, Table } from 'typeorm' 2 | import { Todo } from '../model/todo' 3 | 4 | const todos: Todo[] = [ 5 | { 6 | id: '823a3ec3-d3dc-417d-9931-efc1f5bd296a', 7 | text: 'Buy milk', 8 | done: false, 9 | date_created: new Date(), 10 | date_updated: new Date(), 11 | }, 12 | { 13 | id: '1eac85e0-abee-4f6e-a330-bd8758bb52c4', 14 | text: 'Fix window', 15 | done: false, 16 | date_created: new Date(), 17 | date_updated: new Date(), 18 | }, 19 | { 20 | id: '54ad1235-7ce2-4289-83d1-0c36e619fdad', 21 | text: 'Finish project', 22 | done: false, 23 | date_created: new Date(), 24 | date_updated: new Date(), 25 | }, 26 | { 27 | id: 'ea11861c-eefd-4202-8e98-0053a40d89ec', 28 | text: 'Survive 2020', 29 | done: true, 30 | date_created: new Date(), 31 | date_updated: new Date(), 32 | }, 33 | ] 34 | 35 | export class insertTodos1606672821869 implements MigrationInterface { 36 | public async up(queryRunner: QueryRunner): Promise { 37 | if (queryRunner.connection.name === 'prod') { 38 | await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`) 39 | } 40 | 41 | await queryRunner.createTable( 42 | new Table({ 43 | name: 'todos', 44 | columns: [ 45 | { 46 | name: 'id', 47 | type: 'uuid', 48 | isPrimary: true, 49 | isUnique: true, 50 | generationStrategy: 'uuid', 51 | default: `uuid_generate_v4()`, 52 | }, 53 | { name: 'text', type: 'text', isNullable: false }, 54 | { name: 'done', type: 'boolean', isNullable: false, default: false }, 55 | { name: 'date_created', type: 'timestamp', isNullable: false }, 56 | { name: 'date_updated', type: 'timestamp', isNullable: false }, 57 | ], 58 | }), 59 | true 60 | ) 61 | queryRunner.manager.createQueryBuilder().insert().into('todos').values(todos).execute() 62 | } 63 | 64 | public async down(queryRunner: QueryRunner): Promise { 65 | await queryRunner.manager 66 | .createQueryBuilder() 67 | .delete() 68 | .where('id IN (:...ids)', { id: todos.map((todo) => todo.id) }) 69 | .execute() 70 | 71 | await queryRunner.dropTable('todos') 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /backend/src/repository/model/base.ts: -------------------------------------------------------------------------------- 1 | import { CreateDateColumn, UpdateDateColumn, PrimaryGeneratedColumn } from 'typeorm' 2 | 3 | export interface IBaseModel { 4 | id: string 5 | date_created: Date 6 | date_updated: Date 7 | } 8 | 9 | export class BaseModel implements IBaseModel { 10 | @PrimaryGeneratedColumn('uuid') 11 | public id!: string 12 | 13 | @CreateDateColumn() 14 | public date_created!: Date 15 | 16 | @UpdateDateColumn() 17 | public date_updated!: Date 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/repository/model/todo.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity } from 'typeorm' 2 | import { BaseModel, IBaseModel } from './base' 3 | 4 | export interface ITodoCreateProps { 5 | text: string 6 | done: boolean 7 | } 8 | 9 | export interface ITodo extends ITodoCreateProps, IBaseModel {} 10 | 11 | @Entity('todos') 12 | export class Todo extends BaseModel implements ITodo { 13 | @Column({ 14 | nullable: false, 15 | type: 'text', 16 | }) 17 | public text!: string 18 | 19 | @Column({ 20 | nullable: false, 21 | type: 'boolean', 22 | default: false, 23 | }) 24 | public done!: boolean 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/repository/todo.ts: -------------------------------------------------------------------------------- 1 | import { ITodo, Todo } from './model/todo' 2 | import { BaseRepository } from './base' 3 | 4 | export class TodoRepository extends BaseRepository { 5 | constructor() { 6 | super(Todo) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/routes.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 4 | import { Controller, ValidationService, FieldErrors, ValidateError, TsoaRoute, HttpStatusCodeLiteral, TsoaResponse } from '@tsoa/runtime'; 5 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 6 | import { TodoController } from './controller/todo'; 7 | import * as express from 'express'; 8 | 9 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 10 | 11 | const models: TsoaRoute.Models = { 12 | "Todo": { 13 | "dataType": "refObject", 14 | "properties": { 15 | "id": {"dataType":"string","required":true}, 16 | "date_created": {"dataType":"datetime","required":true}, 17 | "date_updated": {"dataType":"datetime","required":true}, 18 | "text": {"dataType":"string","required":true}, 19 | "done": {"dataType":"boolean","required":true}, 20 | }, 21 | "additionalProperties": false, 22 | }, 23 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 24 | "ITodo": { 25 | "dataType": "refObject", 26 | "properties": { 27 | "text": {"dataType":"string","required":true}, 28 | "done": {"dataType":"boolean","required":true}, 29 | "id": {"dataType":"string","required":true}, 30 | "date_created": {"dataType":"datetime","required":true}, 31 | "date_updated": {"dataType":"datetime","required":true}, 32 | }, 33 | "additionalProperties": false, 34 | }, 35 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 36 | "Pick_Todo.Exclude_keyofTodo.id-or-done-or-date_created-or-date_updated__": { 37 | "dataType": "refAlias", 38 | "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"text":{"dataType":"string","required":true}},"validators":{}}, 39 | }, 40 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 41 | "Omit_Todo.id-or-done-or-date_created-or-date_updated_": { 42 | "dataType": "refAlias", 43 | "type": {"ref":"Pick_Todo.Exclude_keyofTodo.id-or-done-or-date_created-or-date_updated__","validators":{}}, 44 | }, 45 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 46 | "TodoCreationParams": { 47 | "dataType": "refAlias", 48 | "type": {"ref":"Omit_Todo.id-or-done-or-date_created-or-date_updated_","validators":{}}, 49 | }, 50 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 51 | "IUpdateTodoRequest": { 52 | "dataType": "refObject", 53 | "properties": { 54 | "done": {"dataType":"boolean","required":true}, 55 | }, 56 | "additionalProperties": false, 57 | }, 58 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 59 | }; 60 | const validationService = new ValidationService(models); 61 | 62 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 63 | 64 | export function RegisterRoutes(app: express.Router) { 65 | // ########################################################################################################### 66 | // NOTE: If you do not see routes for all of your controllers in this file, then you might not have informed tsoa of where to look 67 | // Please look into the "controllerPathGlobs" config option described in the readme: https://github.com/lukeautry/tsoa 68 | // ########################################################################################################### 69 | app.get('/api/todos/:id', 70 | function TodoController_getById(request: any, response: any, next: any) { 71 | const args = { 72 | id: {"in":"path","name":"id","required":true,"dataType":"string"}, 73 | }; 74 | 75 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 76 | 77 | let validatedArgs: any[] = []; 78 | try { 79 | validatedArgs = getValidatedArgs(args, request, response); 80 | } catch (err) { 81 | return next(err); 82 | } 83 | 84 | const controller = new TodoController(); 85 | 86 | 87 | const promise = controller.getById.apply(controller, validatedArgs as any); 88 | promiseHandler(controller, promise, response, undefined, next); 89 | }); 90 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 91 | app.get('/api/todos', 92 | function TodoController_get(request: any, response: any, next: any) { 93 | const args = { 94 | page: {"default":1,"in":"query","name":"page","dataType":"double"}, 95 | pageSize: {"default":10,"in":"query","name":"pageSize","dataType":"double"}, 96 | }; 97 | 98 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 99 | 100 | let validatedArgs: any[] = []; 101 | try { 102 | validatedArgs = getValidatedArgs(args, request, response); 103 | } catch (err) { 104 | return next(err); 105 | } 106 | 107 | const controller = new TodoController(); 108 | 109 | 110 | const promise = controller.get.apply(controller, validatedArgs as any); 111 | promiseHandler(controller, promise, response, undefined, next); 112 | }); 113 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 114 | app.post('/api/todos', 115 | function TodoController_create(request: any, response: any, next: any) { 116 | const args = { 117 | requestBody: {"in":"body","name":"requestBody","required":true,"ref":"TodoCreationParams"}, 118 | }; 119 | 120 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 121 | 122 | let validatedArgs: any[] = []; 123 | try { 124 | validatedArgs = getValidatedArgs(args, request, response); 125 | } catch (err) { 126 | return next(err); 127 | } 128 | 129 | const controller = new TodoController(); 130 | 131 | 132 | const promise = controller.create.apply(controller, validatedArgs as any); 133 | promiseHandler(controller, promise, response, undefined, next); 134 | }); 135 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 136 | app.post('/api/todos/:id', 137 | function TodoController_updateStatus(request: any, response: any, next: any) { 138 | const args = { 139 | id: {"in":"path","name":"id","required":true,"dataType":"string"}, 140 | requestBody: {"in":"body","name":"requestBody","required":true,"ref":"IUpdateTodoRequest"}, 141 | }; 142 | 143 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 144 | 145 | let validatedArgs: any[] = []; 146 | try { 147 | validatedArgs = getValidatedArgs(args, request, response); 148 | } catch (err) { 149 | return next(err); 150 | } 151 | 152 | const controller = new TodoController(); 153 | 154 | 155 | const promise = controller.updateStatus.apply(controller, validatedArgs as any); 156 | promiseHandler(controller, promise, response, undefined, next); 157 | }); 158 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 159 | app.delete('/api/todos/:id', 160 | function TodoController_delete(request: any, response: any, next: any) { 161 | const args = { 162 | id: {"in":"path","name":"id","required":true,"dataType":"string"}, 163 | }; 164 | 165 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 166 | 167 | let validatedArgs: any[] = []; 168 | try { 169 | validatedArgs = getValidatedArgs(args, request, response); 170 | } catch (err) { 171 | return next(err); 172 | } 173 | 174 | const controller = new TodoController(); 175 | 176 | 177 | const promise = controller.delete.apply(controller, validatedArgs as any); 178 | promiseHandler(controller, promise, response, undefined, next); 179 | }); 180 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 181 | 182 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 183 | 184 | 185 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 186 | 187 | function isController(object: any): object is Controller { 188 | return 'getHeaders' in object && 'getStatus' in object && 'setStatus' in object; 189 | } 190 | 191 | function promiseHandler(controllerObj: any, promise: any, response: any, successStatus: any, next: any) { 192 | return Promise.resolve(promise) 193 | .then((data: any) => { 194 | let statusCode = successStatus; 195 | let headers; 196 | if (isController(controllerObj)) { 197 | headers = controllerObj.getHeaders(); 198 | statusCode = controllerObj.getStatus() || statusCode; 199 | } 200 | 201 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 202 | 203 | returnHandler(response, statusCode, data, headers) 204 | }) 205 | .catch((error: any) => next(error)); 206 | } 207 | 208 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 209 | 210 | function returnHandler(response: any, statusCode?: number, data?: any, headers: any = {}) { 211 | if (response.headersSent) { 212 | return; 213 | } 214 | Object.keys(headers).forEach((name: string) => { 215 | response.set(name, headers[name]); 216 | }); 217 | if (data && typeof data.pipe === 'function' && data.readable && typeof data._read === 'function') { 218 | data.pipe(response); 219 | } else if (data !== null && data !== undefined) { 220 | response.status(statusCode || 200).json(data); 221 | } else { 222 | response.status(statusCode || 204).end(); 223 | } 224 | } 225 | 226 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 227 | 228 | function responder(response: any): TsoaResponse { 229 | return function(status, data, headers) { 230 | returnHandler(response, status, data, headers); 231 | }; 232 | }; 233 | 234 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 235 | 236 | function getValidatedArgs(args: any, request: any, response: any): any[] { 237 | const fieldErrors: FieldErrors = {}; 238 | const values = Object.keys(args).map((key) => { 239 | const name = args[key].name; 240 | switch (args[key].in) { 241 | case 'request': 242 | return request; 243 | case 'query': 244 | return validationService.ValidateParam(args[key], request.query[name], name, fieldErrors, undefined, {"noImplicitAdditionalProperties":"throw-on-extras"}); 245 | case 'path': 246 | return validationService.ValidateParam(args[key], request.params[name], name, fieldErrors, undefined, {"noImplicitAdditionalProperties":"throw-on-extras"}); 247 | case 'header': 248 | return validationService.ValidateParam(args[key], request.header(name), name, fieldErrors, undefined, {"noImplicitAdditionalProperties":"throw-on-extras"}); 249 | case 'body': 250 | return validationService.ValidateParam(args[key], request.body, name, fieldErrors, undefined, {"noImplicitAdditionalProperties":"throw-on-extras"}); 251 | case 'body-prop': 252 | return validationService.ValidateParam(args[key], request.body[name], name, fieldErrors, 'body.', {"noImplicitAdditionalProperties":"throw-on-extras"}); 253 | case 'formData': 254 | if (args[key].dataType === 'file') { 255 | return validationService.ValidateParam(args[key], request.file, name, fieldErrors, undefined, {"noImplicitAdditionalProperties":"throw-on-extras"}); 256 | } else if (args[key].dataType === 'array' && args[key].array.dataType === 'file') { 257 | return validationService.ValidateParam(args[key], request.files, name, fieldErrors, undefined, {"noImplicitAdditionalProperties":"throw-on-extras"}); 258 | } else { 259 | return validationService.ValidateParam(args[key], request.body[name], name, fieldErrors, undefined, {"noImplicitAdditionalProperties":"throw-on-extras"}); 260 | } 261 | case 'res': 262 | return responder(response); 263 | } 264 | }); 265 | 266 | if (Object.keys(fieldErrors).length > 0) { 267 | throw new ValidateError(fieldErrors, ''); 268 | } 269 | return values; 270 | } 271 | 272 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 273 | } 274 | 275 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 276 | -------------------------------------------------------------------------------- /backend/src/server.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | 3 | import http from 'http' 4 | import { promisify } from 'util' 5 | import stoppable from 'stoppable' 6 | 7 | import appExpress from './app' 8 | import logger from './common/logger' 9 | import { connectKafka } from './kafka' 10 | import { getRedisClient } from './redis' 11 | import { environment } from './common/environment' 12 | import { createDBConnection } from './repository/database' 13 | 14 | dotenv.config() 15 | 16 | const TERMINATION_GRACE_PERIOD = 30 17 | 18 | const { PORT, NODE_ENV, LOG_LEVEL, LOCAL_DOCKER } = environment() 19 | 20 | const redis = getRedisClient() 21 | /** ********************************************************************************** 22 | * Create HTTP server 23 | ********************************************************************************** */ 24 | 25 | const app = stoppable(http.createServer(appExpress), TERMINATION_GRACE_PERIOD) 26 | 27 | app.on('listening', async () => { 28 | logger.info(`Server started on PORT="${PORT}", NODE_ENV="${NODE_ENV}", LOG_LEVEL=${LOG_LEVEL}`) 29 | await createDBConnection() 30 | 31 | if (NODE_ENV === 'prod' || LOCAL_DOCKER) { 32 | logger.debug(`Connecting to Redis, NODE_ENV=${NODE_ENV}, LOCAL_DOCKER=${LOCAL_DOCKER}`) 33 | await redis.connect() 34 | logger.debug(`Connecting to Kafka, NODE_ENV=${NODE_ENV}, LOCAL_DOCKER=${LOCAL_DOCKER}`) 35 | await connectKafka() 36 | } else { 37 | logger.debug(`Will not connect to services, NODE_ENV=${NODE_ENV}, LOCAL_DOCKER=${LOCAL_DOCKER}`) 38 | } 39 | 40 | if (NODE_ENV === 'dev') { 41 | logger.info(`Open: http://localhost:${PORT}/api/docs`) 42 | } 43 | }) 44 | 45 | app.on('error', (error: any) => { 46 | if (error.syscall !== 'listen') { 47 | throw error 48 | } 49 | const bind = typeof PORT === 'string' ? `Pipe ${PORT}` : `Port ${PORT}` 50 | 51 | switch (error.code) { 52 | case 'EACCES': 53 | logger.error(`Port: ${bind} requires elevated privileges`) 54 | process.exit(1) 55 | case 'EADDRINUSE': 56 | logger.error(`$Port: ${bind} is already in use`) 57 | process.exit(1) 58 | default: 59 | throw error 60 | } 61 | }) 62 | 63 | const handleSignal = (signal: string) => async () => { 64 | try { 65 | logger.warn(`Got ${signal}. Graceful shutdown start`, new Date().toISOString()) 66 | const stopAppServer = promisify(app.stop) 67 | await Promise.all([stopAppServer()]) 68 | logger.warn('Successful graceful shutdown', new Date().toISOString()) 69 | process.exit(0) 70 | } catch (error) { 71 | logger.warn('Error during graceful shutdown', error) 72 | process.exit(1) 73 | } 74 | } 75 | 76 | process.on('SIGTERM', handleSignal('SIGTERM')) 77 | process.on('SIGINT', handleSignal('SIGINT')) 78 | 79 | // Export http server 80 | export const server = async () => { 81 | app.listen(PORT) 82 | } 83 | -------------------------------------------------------------------------------- /backend/src/service/todo.ts: -------------------------------------------------------------------------------- 1 | import { TodoRepository } from '../repository/todo' 2 | import { ITodo, Todo } from '../repository/model/todo' 3 | import { OperationError } from '../common/errors/operation-error' 4 | import { StatusCodes } from 'http-status-codes' 5 | import { produce } from '../kafka' 6 | 7 | // A POST request should not contain an id. 8 | export type TodoCreationParams = Omit 9 | 10 | interface IGetTodoParams { 11 | page: number 12 | pageSize: number 13 | } 14 | 15 | export interface ICreateTodoRequest { 16 | text: string 17 | } 18 | 19 | export interface IUpdateTodoRequest { 20 | done: boolean 21 | } 22 | 23 | export class TodoService { 24 | public async getById(id: string): Promise { 25 | const todo = await this.repository.findOne({ 26 | where: { 27 | id, 28 | }, 29 | }) 30 | if (!todo) { 31 | throw new OperationError('NOT_FOUND', StatusCodes.NOT_FOUND) 32 | } 33 | return todo 34 | } 35 | 36 | public async get({ page, pageSize }: IGetTodoParams): Promise> { 37 | const take = pageSize 38 | const skip = take && page && (page - 1) * take 39 | 40 | return await this.repository.find({ 41 | take, 42 | skip, 43 | order: { 44 | id: 'ASC', 45 | }, 46 | }) 47 | } 48 | 49 | public async create({ text }: ICreateTodoRequest): Promise { 50 | try { 51 | const created = await this.repository.create({ 52 | text, 53 | done: false, 54 | }) 55 | produce('todo-created', created.id, JSON.stringify(created)) 56 | return created 57 | } catch (err) { 58 | throw new OperationError('UNKNOWN_ERROR', StatusCodes.INTERNAL_SERVER_ERROR) 59 | } 60 | } 61 | 62 | public async update(id: string, { done }: IUpdateTodoRequest): Promise { 63 | try { 64 | const todo = await this.getById(id) 65 | 66 | if (!todo) { 67 | throw new OperationError('NOT_FOUND', StatusCodes.NOT_FOUND) 68 | } 69 | 70 | const updated = await this.repository.update({ 71 | ...todo, 72 | done, 73 | }) 74 | produce('todo-updated', updated.id, JSON.stringify(updated)) 75 | return updated 76 | } catch (err) { 77 | throw new OperationError('UNKNOWN_ERROR', StatusCodes.INTERNAL_SERVER_ERROR) 78 | } 79 | } 80 | 81 | public async delete(id: string) { 82 | try { 83 | return await this.repository.delete({ 84 | id, 85 | }) 86 | } catch (err) { 87 | throw new OperationError('UNKNOWN_ERROR', StatusCodes.INTERNAL_SERVER_ERROR) 88 | } 89 | } 90 | 91 | private get repository() { 92 | return new TodoRepository() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /backend/src/start.ts: -------------------------------------------------------------------------------- 1 | import { server } from './server' 2 | 3 | server() 4 | -------------------------------------------------------------------------------- /backend/src/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "components": { 3 | "examples": {}, 4 | "headers": {}, 5 | "parameters": {}, 6 | "requestBodies": {}, 7 | "responses": {}, 8 | "schemas": { 9 | "Todo": { 10 | "properties": { 11 | "id": { 12 | "type": "string" 13 | }, 14 | "date_created": { 15 | "type": "string", 16 | "format": "date-time" 17 | }, 18 | "date_updated": { 19 | "type": "string", 20 | "format": "date-time" 21 | }, 22 | "text": { 23 | "type": "string" 24 | }, 25 | "done": { 26 | "type": "boolean" 27 | } 28 | }, 29 | "required": [ 30 | "id", 31 | "date_created", 32 | "date_updated", 33 | "text", 34 | "done" 35 | ], 36 | "type": "object", 37 | "additionalProperties": false 38 | }, 39 | "ITodo": { 40 | "properties": { 41 | "text": { 42 | "type": "string" 43 | }, 44 | "done": { 45 | "type": "boolean" 46 | }, 47 | "id": { 48 | "type": "string" 49 | }, 50 | "date_created": { 51 | "type": "string", 52 | "format": "date-time" 53 | }, 54 | "date_updated": { 55 | "type": "string", 56 | "format": "date-time" 57 | } 58 | }, 59 | "required": [ 60 | "text", 61 | "done", 62 | "id", 63 | "date_created", 64 | "date_updated" 65 | ], 66 | "type": "object", 67 | "additionalProperties": false 68 | }, 69 | "Pick_Todo.Exclude_keyofTodo.id-or-done-or-date_created-or-date_updated__": { 70 | "properties": { 71 | "text": { 72 | "type": "string" 73 | } 74 | }, 75 | "required": [ 76 | "text" 77 | ], 78 | "type": "object", 79 | "description": "From T, pick a set of properties whose keys are in the union K" 80 | }, 81 | "Omit_Todo.id-or-done-or-date_created-or-date_updated_": { 82 | "$ref": "#/components/schemas/Pick_Todo.Exclude_keyofTodo.id-or-done-or-date_created-or-date_updated__", 83 | "description": "Construct a type with the properties of T except for those in type K." 84 | }, 85 | "TodoCreationParams": { 86 | "$ref": "#/components/schemas/Omit_Todo.id-or-done-or-date_created-or-date_updated_" 87 | }, 88 | "IUpdateTodoRequest": { 89 | "properties": { 90 | "done": { 91 | "type": "boolean" 92 | } 93 | }, 94 | "required": [ 95 | "done" 96 | ], 97 | "type": "object", 98 | "additionalProperties": false 99 | } 100 | }, 101 | "securitySchemes": {} 102 | }, 103 | "info": { 104 | "title": "demo-project-backend", 105 | "version": "0.0.1", 106 | "description": "Backend of demo-project", 107 | "license": { 108 | "name": "UNLICENSED" 109 | }, 110 | "contact": { 111 | "name": "Raed Chammam", 112 | "email": "raed.chammam@gmail.com", 113 | "url": "https://raed.dev" 114 | } 115 | }, 116 | "openapi": "3.0.0", 117 | "paths": { 118 | "/todos/{id}": { 119 | "get": { 120 | "operationId": "GetById", 121 | "responses": { 122 | "200": { 123 | "description": "Ok", 124 | "content": { 125 | "application/json": { 126 | "schema": { 127 | "$ref": "#/components/schemas/Todo" 128 | } 129 | } 130 | } 131 | } 132 | }, 133 | "tags": [ 134 | "Todo" 135 | ], 136 | "security": [], 137 | "parameters": [ 138 | { 139 | "in": "path", 140 | "name": "id", 141 | "required": true, 142 | "schema": { 143 | "type": "string" 144 | } 145 | } 146 | ] 147 | }, 148 | "post": { 149 | "operationId": "UpdateStatus", 150 | "responses": { 151 | "200": { 152 | "description": "Ok", 153 | "content": { 154 | "application/json": { 155 | "schema": { 156 | "$ref": "#/components/schemas/ITodo" 157 | } 158 | } 159 | } 160 | } 161 | }, 162 | "tags": [ 163 | "Todo" 164 | ], 165 | "security": [], 166 | "parameters": [ 167 | { 168 | "in": "path", 169 | "name": "id", 170 | "required": true, 171 | "schema": { 172 | "type": "string" 173 | } 174 | } 175 | ], 176 | "requestBody": { 177 | "required": true, 178 | "content": { 179 | "application/json": { 180 | "schema": { 181 | "$ref": "#/components/schemas/IUpdateTodoRequest" 182 | } 183 | } 184 | } 185 | } 186 | }, 187 | "delete": { 188 | "operationId": "Delete", 189 | "responses": { 190 | "204": { 191 | "description": "No content" 192 | } 193 | }, 194 | "tags": [ 195 | "Todo" 196 | ], 197 | "security": [], 198 | "parameters": [ 199 | { 200 | "in": "path", 201 | "name": "id", 202 | "required": true, 203 | "schema": { 204 | "type": "string" 205 | } 206 | } 207 | ] 208 | } 209 | }, 210 | "/todos": { 211 | "get": { 212 | "operationId": "Get", 213 | "responses": { 214 | "200": { 215 | "description": "Ok", 216 | "content": { 217 | "application/json": { 218 | "schema": { 219 | "items": { 220 | "$ref": "#/components/schemas/Todo" 221 | }, 222 | "type": "array" 223 | } 224 | } 225 | } 226 | } 227 | }, 228 | "tags": [ 229 | "Todo" 230 | ], 231 | "security": [], 232 | "parameters": [ 233 | { 234 | "in": "query", 235 | "name": "page", 236 | "required": false, 237 | "schema": { 238 | "default": 1, 239 | "format": "double", 240 | "type": "number" 241 | } 242 | }, 243 | { 244 | "in": "query", 245 | "name": "pageSize", 246 | "required": false, 247 | "schema": { 248 | "default": 10, 249 | "format": "double", 250 | "type": "number" 251 | } 252 | } 253 | ] 254 | }, 255 | "post": { 256 | "operationId": "Create", 257 | "responses": { 258 | "201": { 259 | "description": "Created", 260 | "content": { 261 | "application/json": { 262 | "schema": { 263 | "$ref": "#/components/schemas/ITodo" 264 | } 265 | } 266 | } 267 | } 268 | }, 269 | "tags": [ 270 | "Todo" 271 | ], 272 | "security": [], 273 | "parameters": [], 274 | "requestBody": { 275 | "required": true, 276 | "content": { 277 | "application/json": { 278 | "schema": { 279 | "$ref": "#/components/schemas/TodoCreationParams" 280 | } 281 | } 282 | } 283 | } 284 | } 285 | } 286 | }, 287 | "servers": [ 288 | { 289 | "url": "/api" 290 | } 291 | ] 292 | } -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "outDir": "dist", 5 | "rootDir": "src", 6 | "importHelpers": true, 7 | "module": "commonjs", 8 | "strict": true, 9 | "resolveJsonModule": true, 10 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true, 12 | "esModuleInterop": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "downlevelIteration": true, 16 | "moduleResolution": "node", 17 | "target": "es5", 18 | "lib": ["es6"], 19 | "removeComments": true, 20 | "composite": false, 21 | "incremental": true, 22 | "declaration": false, 23 | "declarationMap": false, 24 | "types": ["node"], 25 | "typeRoots": ["node_modules/@types"], 26 | "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" 27 | }, 28 | "include": ["./src", "./src/swagger.json"], 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /backend/tsoa.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryFile": "", 3 | "noImplicitAdditionalProperties": "throw-on-extras", 4 | "controllerPathGlobs": [ 5 | "./src/controller/*" 6 | ], 7 | "spec": { 8 | "outputDirectory": "src", 9 | "specVersion": 3, 10 | "basePath": "/api" 11 | }, 12 | "routes": { 13 | "routesDir": "src", 14 | "basePath": "/api" 15 | } 16 | } -------------------------------------------------------------------------------- /clean.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # ______ _ 4 | # | ___ \ | | 5 | # | |_/ /__ _ ___ __| | 6 | # | // _` |/ _ \/ _` | 7 | # | |\ \ (_| | __/ (_| | 8 | # \_| \_\__,_|\___|\__,_| 9 | # 10 | # v0.0.0 11 | # 12 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 13 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 14 | # PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 15 | # FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 16 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 17 | # OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | 19 | function echo_fail { 20 | printf "\e[31m✘ ${1} \033\e[0m \n" 21 | } 22 | function echo_pass { 23 | printf "\e[32m✔ ${1} \033\e[0m \n" 24 | } 25 | 26 | # Docker 27 | function check_docker_running { 28 | `docker info > /dev/null 2>&1` || { echo_fail "🐳 Docker is not running (ℹ️ Start docker and run this script again)" ; exit 1; } 29 | } 30 | 31 | # Yarn 32 | function clean_js_build { 33 | yarn --cwd $1 clean --silent && { echo_pass "✨ Cleaning success" ; } || { echo_fail "Something went wrong" ; exit 1; } 34 | } 35 | 36 | # Main function 37 | echo_pass "Sit back, everything is under control... 🧹" 38 | 39 | check_docker_running 40 | 41 | # Clean docker-compose 42 | echo "🧹 🐳 Cleaning docker-compose\n" 43 | docker-compose -f .docker/docker-compose.yml stop 44 | docker-compose -f .docker/docker-compose.yml down --volumes --remove-orphans --rmi all 45 | echo_pass "✨ Removed docker images" 46 | 47 | echo "🧹 📦 Cleaning builds\n" 48 | clean_js_build "frontend" 49 | clean_js_build "backend" 50 | clean_js_build "backend-realtime" 51 | 52 | echo "🧹 📦 Cleaning node_modules\n" 53 | find . -name "node_modules" -type d -prune | xargs du -chs 54 | find . -name "node_modules" -type d -prune -exec rm -rf '{}' + 55 | echo_pass "✨ Cleaned node_modules" 56 | 57 | echo_pass "✅ All done" 58 | 59 | exit 0 60 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .github 3 | .vscode 4 | *.sqlite 5 | node_modules 6 | .env 7 | .eslintignore 8 | .eslintrc 9 | .gitignore 10 | .git 11 | .dockerignore 12 | .editorconfig 13 | .prettierignore 14 | .prettierrc 15 | .eslintcache 16 | Dockerfile 17 | readme.md 18 | dist 19 | tsconfig.tsbuildinfo 20 | docker-compose.yml -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .eslintcache 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /frontend/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | always-auth=false -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "es5" 6 | } -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # - Build frontend app 2 | FROM node:lts-alpine as builder 3 | 4 | COPY package.json yarn.lock .npmrc /app/ 5 | 6 | WORKDIR /app 7 | 8 | RUN yarn install 9 | 10 | COPY . /app 11 | 12 | RUN yarn build 13 | 14 | # - Serve static files 15 | FROM nginx:stable-alpine as prod 16 | 17 | COPY --from=builder /app/build/ /usr/share/nginx/html 18 | COPY --from=builder /app/nginx/default.conf /etc/nginx/conf.d/default.conf 19 | 20 | EXPOSE 8080 21 | 22 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /frontend/nginx/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 8080; 3 | 4 | location / { 5 | root /usr/share/nginx/html; 6 | index index.html index.htm; 7 | try_files $uri $uri/ /index.html; 8 | } 9 | 10 | include /etc/nginx/extra-conf.d/*.conf; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-project-frontend", 3 | "private": true, 4 | "version": "0.0.1", 5 | "license": "UNLICENSED", 6 | "description": "Frontend of demo-project", 7 | "author": { 8 | "name": "Raed Chammam", 9 | "email": "raed.chammam@gmail.com", 10 | "url": "https://raed.dev" 11 | }, 12 | "proxy": "http://localhost:3000", 13 | "dependencies": { 14 | "@material-ui/core": "^4.12.3", 15 | "@material-ui/data-grid": "^4.0.0-alpha.29", 16 | "@material-ui/icons": "^4.9.1", 17 | "@material-ui/lab": "^4.0.0-alpha.58", 18 | "@testing-library/jest-dom": "^5.16.2", 19 | "@testing-library/react": "^11.2.7", 20 | "@testing-library/user-event": "^12.1.10", 21 | "@types/jest": "^26.0.23", 22 | "@types/node": "^12.0.0", 23 | "@types/react": "^17.0.39", 24 | "@types/react-dom": "^17.0.13", 25 | "axios": "^0.21.0", 26 | "final-form": "^4.20.6", 27 | "mui-rff": "^2.5.6", 28 | "react": "^17.0.2", 29 | "react-dom": "^17.0.2", 30 | "react-error-boundary": "^3.1.4", 31 | "react-final-form": "^6.5.8", 32 | "react-router-dom": "^5.2.0", 33 | "react-scripts": "^4.0.3", 34 | "react-use-websocket": "^2.7.1", 35 | "typescript": "^4.6.2", 36 | "yup": "^0.32.11" 37 | }, 38 | "devDependencies": { 39 | "@types/react-router-dom": "^5.3.3", 40 | "@types/yup": "^0.29.13", 41 | "cross-env": "^7.0.2", 42 | "eslint-config-prettier": "^8.5.0", 43 | "eslint-plugin-prettier": "^4.0.0", 44 | "eslint-plugin-react": "^7.29.3", 45 | "http-proxy-middleware": "^2.0.3", 46 | "prettier": "^2.5.1", 47 | "prettier-plugin-organize-imports": "^2.3.4", 48 | "rimraf": "^3.0.2" 49 | }, 50 | "scripts": { 51 | "start": "cross-env PORT=3006 react-scripts start", 52 | "build": "react-scripts build", 53 | "clean": "rimraf ./build", 54 | "test": "react-scripts test", 55 | "eject": "react-scripts eject", 56 | "lint": "tsc --noEmit && eslint --ext .ts --ext .tsx ./src", 57 | "lint:format": "prettier --write 'src/**/*.+(ts|tsx)'", 58 | "docker:build": "docker build --tag raedchammam/demo-project-frontend .", 59 | "docker:run": "docker run -d -p 80:80 --name frontend raedchammam/demo-project-frontend", 60 | "docker:stop-container": "docker stop frontend", 61 | "docker:remove-container": "docker rm frontend", 62 | "docker:remove-image": "docker rmi raedchammam/demo-project-frontend" 63 | }, 64 | "eslintConfig": { 65 | "extends": [ 66 | "react-app", 67 | "react-app/jest" 68 | ] 69 | }, 70 | "browserslist": { 71 | "production": [ 72 | ">0.2%", 73 | "not dead", 74 | "not op_mini all" 75 | ], 76 | "development": [ 77 | "last 1 chrome version", 78 | "last 1 firefox version", 79 | "last 1 safari version" 80 | ] 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raed667/demo-project/7163df45108ec3142b19c1373db42afd8ba6fcf6/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |

32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raed667/demo-project/7163df45108ec3142b19c1373db42afd8ba6fcf6/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raed667/demo-project/7163df45108ec3142b19c1373db42afd8ba6fcf6/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Container } from '@material-ui/core' 2 | import React from 'react' 3 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom' 4 | import { Navigation } from './components/Navigation' 5 | import { About } from './pages/About' 6 | import { Home } from './pages/Home' 7 | 8 | export const App = () => ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ) 25 | -------------------------------------------------------------------------------- /frontend/src/components/ErrorFallback/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useStyles } from './style' 3 | 4 | type Props = { 5 | resetErrorBoundary: () => void 6 | } 7 | export const ErrorFallback = ({ resetErrorBoundary }: Props) => { 8 | const classes = useStyles() 9 | 10 | return ( 11 |
12 |

Something went wrong:

13 | 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/components/ErrorFallback/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/core/styles' 2 | 3 | export const useStyles = makeStyles((theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | }, 7 | })) 8 | -------------------------------------------------------------------------------- /frontend/src/components/Navigation/index.tsx: -------------------------------------------------------------------------------- 1 | import { AppBar, Toolbar, Typography } from '@material-ui/core' 2 | import React from 'react' 3 | import { Link } from 'react-router-dom' 4 | import { useStyles } from './style' 5 | 6 | export const Navigation = () => { 7 | const classes = useStyles() 8 | 9 | return ( 10 |
11 | 12 | 13 | 14 | 15 | Demo App 16 | 17 | 18 | 19 | About this app 20 | 21 | 22 | 23 | 24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/Navigation/style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/core/styles' 2 | 3 | export const useStyles = makeStyles((theme) => ({ 4 | root: { 5 | flexGrow: 1, 6 | }, 7 | 8 | title: { 9 | flexGrow: 1, 10 | display: 'flex', 11 | justifyContent: 'space-around', 12 | }, 13 | link: { textDecoration: 'none', color: theme.palette.common.white }, 14 | })) 15 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import CssBaseline from '@material-ui/core/CssBaseline' 2 | import { ThemeProvider } from '@material-ui/core/styles' 3 | import React from 'react' 4 | import ReactDOM from 'react-dom' 5 | import { App } from './App' 6 | import { theme } from './theme' 7 | 8 | ReactDOM.render( 9 | 10 | 11 | 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ) 17 | -------------------------------------------------------------------------------- /frontend/src/pages/About/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Paper, Typography } from '@material-ui/core' 2 | import React from 'react' 3 | import { useStyles } from './style' 4 | 5 | export const About = () => { 6 | const classes = useStyles() 7 | 8 | return ( 9 | 10 | 11 | Welcome to an extremely over-engineered todo app! 12 | 13 |
14 | Check under the hood: 15 |
16 |
17 | 25 | 28 |
29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/pages/About/style.ts: -------------------------------------------------------------------------------- 1 | import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' 2 | 3 | export const useStyles = makeStyles((theme: Theme) => 4 | createStyles({ 5 | wrapper: { 6 | padding: theme.spacing(2), 7 | width: '100%', 8 | height: 'calc(100vh - 130px)', 9 | }, 10 | container: { 11 | display: 'flex', 12 | justifyContent: 'space-around', 13 | marginBottom: theme.spacing(2), 14 | }, 15 | }) 16 | ) 17 | -------------------------------------------------------------------------------- /frontend/src/pages/Home/components/AddDialog/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@material-ui/core' 2 | import { makeRequired, makeValidate, TextField } from 'mui-rff' 3 | import React from 'react' 4 | import { Form } from 'react-final-form' 5 | import * as Yup from 'yup' 6 | import { useStyles } from './style' 7 | type Props = { 8 | isOpen: boolean 9 | isLoading: boolean 10 | onCancel: () => void 11 | onSubmit: (state: State) => void 12 | } 13 | 14 | type State = { 15 | text: string 16 | } 17 | 18 | const schema = Yup.object().shape({ 19 | text: Yup.string().required('Text is required'), 20 | }) 21 | 22 | const validate = makeValidate(schema) 23 | const required = makeRequired(schema) 24 | 25 | export const AddDialog = ({ isOpen, isLoading, onCancel, onSubmit }: Props) => { 26 | const classes = useStyles() 27 | 28 | const handleCancel = () => { 29 | onCancel() 30 | } 31 | 32 | return ( 33 | 34 | Add new item 35 | 36 |
( 40 | 41 | 49 | 58 | 59 | )} 60 | /> 61 |
62 | 63 | 66 | 67 |
68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /frontend/src/pages/Home/components/AddDialog/style.ts: -------------------------------------------------------------------------------- 1 | import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' 2 | 3 | export const useStyles = makeStyles((theme: Theme) => 4 | createStyles({ 5 | form: { 6 | display: 'flex', 7 | flexDirection: 'column', 8 | justifyContent: 'space-around', 9 | height: 250, 10 | width: 250, 11 | }, 12 | }) 13 | ) 14 | -------------------------------------------------------------------------------- /frontend/src/pages/Home/components/DeleteDialog/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Dialog, 4 | DialogActions, 5 | DialogContent, 6 | DialogContentText, 7 | DialogTitle, 8 | } from '@material-ui/core' 9 | 10 | type Props = { 11 | isOpen: boolean 12 | isLoading: boolean 13 | onCancel: () => void 14 | onSubmit: () => void 15 | } 16 | 17 | export const DeleteDialog = ({ isOpen, isLoading, onCancel, onSubmit }: Props) => { 18 | const handleCancel = () => { 19 | onCancel() 20 | } 21 | 22 | const handleSubmit = () => { 23 | onSubmit() 24 | } 25 | 26 | return ( 27 | 28 | Are you sure? 29 | 30 | It will be permanently deleted! 31 | 32 | 33 | 36 | 39 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/pages/Home/components/RealtimeDrawer/index.tsx: -------------------------------------------------------------------------------- 1 | import { Divider, Drawer } from '@material-ui/core' 2 | import React from 'react' 3 | import useWebSocket from 'react-use-websocket' 4 | import { useStyles } from './style' 5 | 6 | type Todo = { 7 | id: string 8 | text: string 9 | done: boolean 10 | } 11 | 12 | type Payload = { 13 | todo: Todo 14 | timestamp: string 15 | } 16 | 17 | export const RealtimeDrawer = () => { 18 | const classes = useStyles() 19 | 20 | const [messages, setMessages] = React.useState([]) 21 | 22 | const { readyState, lastJsonMessage } = useWebSocket('ws://localhost/wss/') 23 | 24 | React.useEffect(() => { 25 | if (!lastJsonMessage) return 26 | const payload = JSON.parse(lastJsonMessage) as Payload 27 | 28 | setMessages((prev) => { 29 | const isInList = !!prev.find((u) => u.id === payload.todo.id) 30 | if (isInList) return prev 31 | 32 | return [...prev, payload.todo] 33 | }) 34 | }, [lastJsonMessage]) 35 | 36 | return ( 37 | 45 |
46 | 47 | {readyState === 0 &&
Connecting...
} 48 | {readyState === 2 || (readyState === 3 &&
Closed
)} 49 | {readyState === 1 && ( 50 |
51 | {messages.map((todo) => ( 52 |
53 | {todo.text} 54 | 55 |
56 | ))} 57 |
58 | )} 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /frontend/src/pages/Home/components/RealtimeDrawer/style.ts: -------------------------------------------------------------------------------- 1 | import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' 2 | 3 | const drawerWidth = 240 4 | 5 | export const useStyles = makeStyles((theme: Theme) => 6 | createStyles({ 7 | drawer: { 8 | width: drawerWidth, 9 | flexShrink: 0, 10 | }, 11 | drawerPaper: { 12 | width: drawerWidth, 13 | }, 14 | // necessary for content to be below app bar 15 | toolbar: theme.mixins.toolbar, 16 | }) 17 | ) 18 | -------------------------------------------------------------------------------- /frontend/src/pages/Home/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import axios from '../../../utils/axios' 2 | 3 | export const getTodoList = async (page = 1, pageSize = 100) => { 4 | const { data } = await axios.get('/todos', { 5 | params: { 6 | page, 7 | pageSize, 8 | }, 9 | }) 10 | return data 11 | } 12 | 13 | export const deleteTodo = async (ids: string[]) => { 14 | const promises = ids.map((id) => axios.delete(`/todos/${id}`)) 15 | return await Promise.allSettled(promises) 16 | } 17 | 18 | export const createTodo = async (text: string) => { 19 | return await axios.post('/todos', { 20 | text, 21 | }) 22 | } 23 | 24 | export const updateTodo = async (id: string, done: boolean) => { 25 | return await axios.post(`/todos/${id}`, { 26 | done, 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/pages/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Paper, Typography } from '@material-ui/core' 2 | import { 3 | DataGrid, 4 | GridColDef, 5 | GridRowModel, 6 | GridSelectionModelChangeParams, 7 | GridValueFormatterParams, 8 | GridValueGetterParams, 9 | } from '@material-ui/data-grid' 10 | import CheckCircleOutlineOutlinedIcon from '@material-ui/icons/CheckCircleOutlineOutlined' 11 | import RadioButtonUncheckedOutlinedIcon from '@material-ui/icons/RadioButtonUncheckedOutlined' 12 | import React from 'react' 13 | import { ErrorBoundary } from 'react-error-boundary' 14 | import { ErrorFallback } from '../../components/ErrorFallback' 15 | import { AddDialog } from './components/AddDialog' 16 | import { DeleteDialog } from './components/DeleteDialog' 17 | import { RealtimeDrawer } from './components/RealtimeDrawer' 18 | import { createTodo, deleteTodo, getTodoList, updateTodo } from './helpers' 19 | import { useStyles } from './style' 20 | 21 | export const Home = () => { 22 | const classes = useStyles() 23 | 24 | const [todoList, setTodoList] = React.useState([]) 25 | const [selected, setSelected] = React.useState([]) 26 | const [isLoading, setLoading] = React.useState(false) 27 | const [isDeleteDialogOpen, setDeleteDialogOpen] = React.useState(false) 28 | const [isAddDialogOpen, setAddDialogOpen] = React.useState(false) 29 | 30 | React.useEffect(() => { 31 | ;(async () => { 32 | try { 33 | setLoading(true) 34 | const data = await getTodoList() 35 | setTodoList(data) 36 | } catch (error) { 37 | console.log({ error }) 38 | } finally { 39 | setLoading(false) 40 | } 41 | })() 42 | }, []) 43 | 44 | const onSelectionModelChange = ({ selectionModel }: GridSelectionModelChangeParams) => { 45 | setSelected(selectionModel.map((id) => id.toString())) 46 | } 47 | 48 | const onAcceptDelete = async () => { 49 | setLoading(true) 50 | await deleteTodo(selected) 51 | setTodoList((old) => old.filter((u) => !selected.includes(`${u.id}`))) 52 | setLoading(false) 53 | setDeleteDialogOpen(false) 54 | } 55 | 56 | const onSubmitAdd = async ({ text }: { text: string }) => { 57 | setLoading(true) 58 | await createTodo(text) 59 | 60 | const data = await getTodoList() 61 | setTodoList(data) 62 | 63 | setLoading(false) 64 | setAddDialogOpen(false) 65 | } 66 | 67 | const columns: GridColDef[] = [ 68 | { 69 | field: 'id', 70 | headerName: 'ID', 71 | width: 130, 72 | valueGetter: (params: GridValueGetterParams) => params?.value?.toString().substr(0, 8) || '', 73 | disableClickEventBubbling: true, 74 | }, 75 | { field: 'text', headerName: 'Todo', width: 250, disableClickEventBubbling: true }, 76 | { 77 | field: 'done', 78 | headerName: 'Done', 79 | width: 250, 80 | disableClickEventBubbling: true, 81 | renderCell: (params: GridValueFormatterParams) => { 82 | const onClick = () => { 83 | updateTodo(`${params.row.id}`, !params.value) 84 | setTodoList((old) => 85 | old.map((u) => { 86 | if (u.id === `${params.row.id}`) { 87 | u.done = !params.value 88 | } 89 | return u 90 | }) 91 | ) 92 | } 93 | return ( 94 | 110 | ) 111 | }, 112 | }, 113 | ] 114 | 115 | return ( 116 | { 119 | alert('no') 120 | }} 121 | > 122 | 123 | 124 | 125 | Welcome to the over-complicated Todo list! 126 | 127 | 128 | 129 | A very basic React interface to a CRUD Todo list. 130 | 131 | 132 |
133 |
134 | 142 | 152 |
153 | 161 |
162 |
163 | setDeleteDialogOpen(false)} 167 | isLoading={isLoading} 168 | /> 169 | setAddDialogOpen(false)} 173 | isLoading={isLoading} 174 | /> 175 |
176 | ) 177 | } 178 | -------------------------------------------------------------------------------- /frontend/src/pages/Home/style.ts: -------------------------------------------------------------------------------- 1 | import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' 2 | 3 | export const useStyles = makeStyles((theme: Theme) => 4 | createStyles({ 5 | wrapper: { 6 | padding: theme.spacing(2), 7 | width: '100%', 8 | height: 'calc(100vh - 130px)', 9 | }, 10 | tableWrapper: { 11 | height: 400, 12 | width: '100%', 13 | }, 14 | buttons: { 15 | width: '100%', 16 | display: 'flex', 17 | justifyContent: 'flex-end', 18 | marginBottom: theme.spacing(2), 19 | '& button:first-child': { 20 | marginRight: theme.spacing(1), 21 | }, 22 | }, 23 | }) 24 | ) 25 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom' 6 | -------------------------------------------------------------------------------- /frontend/src/theme/index.tsx: -------------------------------------------------------------------------------- 1 | import { red } from '@material-ui/core/colors' 2 | import { unstable_createMuiStrictModeTheme as createMuiTheme } from '@material-ui/core/styles' 3 | 4 | // A custom theme for this app 5 | export const theme = createMuiTheme({ 6 | palette: { 7 | primary: { 8 | main: '#000', 9 | }, 10 | secondary: { 11 | main: '#19857b', 12 | }, 13 | error: { 14 | main: red.A400, 15 | }, 16 | background: { 17 | default: '#F5F5F3', 18 | }, 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /frontend/src/utils/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const axiosInstance = axios.create({ 4 | baseURL: '/api', 5 | }) 6 | 7 | export default axiosInstance 8 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-project", 3 | "version": "0.0.0", 4 | "private": "true", 5 | "repository": "git@github.com:Raed667/demo-project.git", 6 | "author": "Raed Chammam ", 7 | "devDependencies": { 8 | "k6": "^0.0.0", 9 | "typescript": "^4.6.2" 10 | }, 11 | "dependencies": { 12 | "openapi-typescript-codegen": "^0.20.1", 13 | "rimraf": "^3.0.2" 14 | }, 15 | "scripts": { 16 | "setup": "sh ./setup.sh -y", 17 | "clean": "sh ./clean.sh", 18 | "load-test": "k6 run src/load_testing/hurtMe.js" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Demo-Project: Full-stack TypeScript Application 2 | 3 | Status: On hold until refactor using [Nest](https://nestjs.com/) 4 | 5 | ![backend](https://github.com/Raed667/demo-project/workflows/backend/badge.svg) 6 | ![backend-realtime](https://github.com/Raed667/demo-project/workflows/backend-realtime/badge.svg) 7 | ![frontend](https://github.com/Raed667/demo-project/workflows/frontend/badge.svg) 8 | 9 | ![chart](https://i.imgur.com/6ELu2fx.png) 10 | 11 | A demo project to showcase different technologies and how they work together. 12 | The ultimate goal is to have a full-stack, production-ready application. 13 | 14 | ![ui](https://i.imgur.com/W7Armo5.png) 15 | 16 | You can use the `setup.sh` script to run it locally. 17 | 18 | [![asciicast](https://asciinema.org/a/lKhIcJG0YXbZfvkOpoWtzMUrM.svg)](https://asciinema.org/a/lKhIcJG0YXbZfvkOpoWtzMUrM) 19 | 20 | The goal for this project is to be a "domain driven" starter that will help you 21 | bootstrap a new application in seconds and just work your business logic instead of all the bootstrapping. 22 | 23 | Inspired by [ts-app](https://github.com/lukeautry/ts-app), this project aims to be a little lighter version of that project, with clearer separation of frontend and backend code. And a bigger focus and metrics and monitoring. 24 | 25 | ## Where do I start 26 | 27 | You can start by running: 28 | 29 | ```sh 30 | sh ./setup.sh 31 | ``` 32 | 33 | You can also start the project locally: 34 | 35 | ```sh 36 | yarn dev 37 | ``` 38 | 39 | After you finish you can clean your setup with: 40 | 41 | ```sh 42 | sh ./clean.sh 43 | ``` 44 | 45 | ## Status 46 | 47 | Work in progress. 48 | 49 | ## System Requirements 50 | 51 | - [Node.js 12+](https://nodejs.org/en/download/) 52 | - [docker](https://www.docker.com) and [docker-compose](https://docs.docker.com/compose) 53 | - [yarn](https://yarnpkg.com/en) 54 | 55 | ## Technologies Used 56 | 57 | - [TypeScript](http://www.typescriptlang.org/) 58 | - Backend 59 | 60 | - [Node.js](https://nodejs.org) 61 | - [Express](https://expressjs.com/) 62 | - [tsoa](https://github.com/lukeautry/tsoa) 63 | - Generates Express Routes from TypeScript controllers 64 | - Generates [OpenAPI ("Swagger")](https://swagger.io/docs/specification/about) specification, enabling automatic documentation 65 | - [SQLite](https://www.sqlite.org/index.html) as a local development database 66 | - [PostgreSQL](https://www.postgresql.org/) as RDBMS 67 | - [TypeORM](http://typeorm.io) for code-first database migrations and ORM queries 68 | - [Redis](https://redis.io/) for caching 69 | 70 | - Developer environment 71 | 72 | - [docker](https://www.docker.com/) and [docker-compose](https://docs.docker.com/compose) for non-Node.js project dependencies 73 | - VSCode, eslint and prettier for a consistent development experience 74 | - A setup script `setup.sh` to help you get started in a guided way. 75 | 76 | - Frontend 77 | 78 | - [create-react-app](https://github.com/facebook/create-react-app) 79 | - [material-ui](https://material-ui.com/) 80 | - [final-form](https://github.com/final-form/final-form) 81 | 82 | - Testing 83 | - TBD 84 | 85 | ## Todo 86 | 87 | A high-level list of 88 | 89 | - [x] SQLite in dev 90 | - [x] PG in docker 91 | - [x] Redis for cache 92 | - [x] Setup script 93 | - [x] Setup initial DB data in a migration 94 | - [x] Prometheus for metrics 95 | - [x] Grafana for monitoring 96 | - [x] Frontend: CRA for client 97 | - [x] Frontend: validation using Yup, mui-rff 98 | - [x] Cache redis on get /users query 99 | - [x] Kafka producer for real-time events 100 | - [x] Nginx for web-server 101 | - [x] Kafka consumer for real-time events 102 | - [x] Web-Socket 103 | - [x] Start-up script 104 | - [ ] Refactor backend to use Nest 105 | - [ ] Refactor frontend to use Next (or remix) 106 | - [ ] Secure Nginx 107 | - [ ] Generate client from swagger.json 108 | - [ ] Deploy on a production environment (OVH, GCP, AWS..) 109 | 110 | 111 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FRaedsLab%2Fdemo-project.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2FRaedsLab%2Fdemo-project?ref=badge_large) 112 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # ______ _ 4 | # | ___ \ | | 5 | # | |_/ /__ _ ___ __| | 6 | # | // _` |/ _ \/ _` | 7 | # | |\ \ (_| | __/ (_| | 8 | # \_| \_\__,_|\___|\__,_| 9 | # 10 | # v0.0.0 11 | # 12 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 13 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 14 | # PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 15 | # FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 16 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 17 | # OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | 19 | yflag= 20 | 21 | while getopts yn: name 22 | do 23 | case $name in 24 | y) yflag=1;; 25 | ?) printf "Usage: %s: [-y]\n" $0 26 | exit 2;; 27 | esac 28 | done 29 | 30 | MIN_NODE_VERSION=12 31 | 32 | # Helper functions 33 | function program_is_installed { 34 | local return_=1 35 | type $1 >/dev/null 2>&1 || { local return_=0; } 36 | echo "$return_" 37 | } 38 | function echo_fail { 39 | printf "\e[31m✘ ${1} \033\e[0m \n" 40 | } 41 | function echo_pass { 42 | printf "\e[32m✔ ${1} \033\e[0m \n" 43 | } 44 | 45 | # Check if node, yarn, and docker are installed 46 | function check_installed_dependencies { 47 | [ "$(program_is_installed node)" != 1 ] && echo_fail "❇️ Node.js not found" && exit 1 48 | [ "$(program_is_installed yarn)" != 1 ] && echo_fail "🧵 Yarn not found" && exit 1 49 | [ "$(program_is_installed docker)" != 1 ] && echo_fail "🐳 Docker not found" && exit 1 50 | 51 | NODE_VERSION=`node -v | grep "v" | cut -c2- | awk -F. '{print $1}'` 52 | [ $NODE_VERSION -lt $MIN_NODE_VERSION ] && echo_fail "Node.js needs at least v$MIN_NODE_VERSION, instead found v$NODE_VERSION" && exit 1 53 | } 54 | 55 | # Yarn install 56 | function install_global_js_dependencies { 57 | yarn install --check-files --silent && { echo_pass "✨ Install success" ; } || { echo_fail "Something went wrong" ; exit 1; } 58 | } 59 | 60 | function install_js_dependencies { 61 | yarn --cwd $1 install --check-files --silent && { echo_pass "✨ Install success" ; } || { echo_fail "Something went wrong" ; exit 1; } 62 | } 63 | 64 | # Docker 65 | function check_docker_running { 66 | `docker info > /dev/null 2>&1` || { echo_fail "🐳 Docker is not running (ℹ️ Start docker and run this script again)" ; exit 1; } 67 | } 68 | 69 | function remove_old_docker_images { 70 | if docker images | grep demo-project-$1 ; then 71 | echo "🧹🐳 Removing old \"demo-project-$1\" Docker images" 72 | docker rmi raedchammam/demo-project-$1 --force 73 | fi 74 | } 75 | 76 | function build_docker_images { 77 | yarn --cwd $1 docker:build && { echo_pass "✨ Docker $1 build success" ; } || { echo echo_fail "Something went wrong" ; exit 1; } 78 | } 79 | 80 | # Main function 81 | echo_pass "Sit back, everything is under control... 🚀" 82 | check_installed_dependencies 83 | 84 | echo "📦 Installing project dependencies..." 85 | install_global_js_dependencies 86 | 87 | echo "📦 Installing frontend dependencies..." 88 | install_js_dependencies "frontend" 89 | 90 | echo "📦 Installing backend dependencies..." 91 | install_js_dependencies "backend" 92 | 93 | echo "📦 Installing real-time dependencies..." 94 | install_js_dependencies "backend-realtime" 95 | 96 | check_docker_running 97 | 98 | if [ ! -z "$yflag" ] 99 | then 100 | echo_pass "Lets do this..." 101 | else 102 | while true; do 103 | read -p "🐳 Do you want to build and start the docker images? (y/N) " yn 104 | case $yn in 105 | [Yy]* ) echo "🐳 Building docker images from source...\n"; break;; 106 | * ) echo_pass "✅ All done, you can run \"yarn dev\" to start the project locally"; exit;; 107 | esac 108 | done 109 | fi 110 | 111 | # Clean old builds 112 | remove_old_docker_images "backend" 113 | remove_old_docker_images "frontend" 114 | remove_old_docker_images "backend-realtime" 115 | # Build new images 116 | build_docker_images "backend" 117 | build_docker_images "frontend" 118 | build_docker_images "backend-realtime" 119 | 120 | # Clean docker-compose before start 121 | echo "🧹 🐳 Cleaning docker-compose before starting\n" 122 | docker-compose -f .docker/docker-compose.yml stop 123 | docker-compose -f .docker/docker-compose.yml down --volumes 124 | # Start docker-compose 125 | docker-compose -f .docker/docker-compose.yml up -d && { echo_pass "✨ Started images" ; } || { echo echo_fail "Exiting docker-compose" ; exit 0; } 126 | 127 | echo "💻 Starting browser (swagger): http://localhost:3000/docs" 128 | sleep 3 # Artificial delay to allow for service to be up 129 | open "http://localhost:3000/api/docs" 130 | 131 | echo "💻 Starting browser (Frontend): http://localhost" 132 | sleep 3 # Artificial delay to allow for service to be up 133 | open "http://localhost" 134 | 135 | exit 0 136 | -------------------------------------------------------------------------------- /src/generate_client/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require("path"); 4 | const rimraf = require("rimraf"); 5 | const codegen = require("openapi-typescript-codegen"); 6 | 7 | const inputFile = path.join(__dirname, "../../backend/dist/swagger.json"); 8 | const outputDir = path.join(__dirname, "../../frontend/src/rest-client"); 9 | 10 | rimraf.sync(outputDir); 11 | 12 | codegen.generate({ 13 | input: inputFile, 14 | output: outputDir, 15 | useOptions: true, 16 | httpClient: codegen.HttpClient.XHR, 17 | }); 18 | -------------------------------------------------------------------------------- /src/load_testing/hurtMe.js: -------------------------------------------------------------------------------- 1 | import http from "k6/http"; 2 | import { check, sleep } from "k6"; 3 | import * as faker from "https://raw.githubusercontent.com/Marak/faker.js/9c65e5dd4902dbdf12088c36c098a9d7958afe09/dist/faker.min.js"; 4 | 5 | export let options = { 6 | stages: [ 7 | { duration: "30s", target: 1 }, 8 | { duration: "2m30s", target: 10 }, 9 | { duration: "30s", target: 0 }, 10 | ], 11 | }; 12 | 13 | const CREATED_TODO_COUNT = 10; // 5000; 14 | 15 | export function setup() { 16 | const todoIds = []; 17 | 18 | for (let i = 0; i < CREATED_TODO_COUNT; i++) { 19 | const id = createTodo(); 20 | todoIds.push(id); 21 | } 22 | 23 | return todoIds; 24 | } 25 | 26 | export default function (todoIds) { 27 | // console.log("vu", JSON.stringify({ data })); 28 | todoIds.forEach((id) => { 29 | getTodo(id); 30 | }); 31 | sleep(0.25); 32 | // temporary 33 | const newId = createTodo(); 34 | sleep(0.25); 35 | getTodo(newId); 36 | sleep(0.25); 37 | deleteTodo(newId); 38 | } 39 | 40 | export function teardown(todoIds) { 41 | todoIds.forEach((id) => { 42 | deleteTodo(id); 43 | }); 44 | } 45 | 46 | /** 47 | * Helpers 48 | */ 49 | const random = () => { 50 | return { 51 | text: faker.lorem.sentence(), 52 | }; 53 | }; 54 | 55 | const url = "http://localhost/api/todos"; 56 | 57 | const createTodo = () => { 58 | const payload = JSON.stringify(random()); 59 | 60 | const result = http.post(url, payload, { 61 | tags: { name: "Post" }, 62 | headers: { 63 | "Content-Type": "application/json", 64 | }, 65 | }); 66 | 67 | check(result, { "status was 201": (r) => r.status == 201 }); 68 | 69 | const created = JSON.parse(result.body); 70 | return created.id; 71 | }; 72 | 73 | const getTodo = (id) => { 74 | const res = http.get(url + "/" + id, { tags: { name: "Get" } }); 75 | check(res, { "status was 200": (r) => r.status == 200 }); 76 | return res; 77 | }; 78 | 79 | const deleteTodo = (id) => { 80 | const res = http.del(url + "/" + id, { tags: { name: "Delete" } }); 81 | check(res, { "status was 200": (r) => r.status == 204 }); 82 | }; 83 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "strictNullChecks": true 5 | }, 6 | "include": ["backend", "frontend"] 7 | } 8 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@apidevtools/json-schema-ref-parser@9.0.9": 6 | version "9.0.9" 7 | resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz#d720f9256e3609621280584f2b47ae165359268b" 8 | integrity sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w== 9 | dependencies: 10 | "@jsdevtools/ono" "^7.1.3" 11 | "@types/json-schema" "^7.0.6" 12 | call-me-maybe "^1.0.1" 13 | js-yaml "^4.1.0" 14 | 15 | "@jsdevtools/ono@^7.1.3": 16 | version "7.1.3" 17 | resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" 18 | integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== 19 | 20 | "@types/json-schema@^7.0.6": 21 | version "7.0.9" 22 | resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" 23 | integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ== 24 | 25 | argparse@^2.0.1: 26 | version "2.0.1" 27 | resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" 28 | integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== 29 | 30 | balanced-match@^1.0.0: 31 | version "1.0.0" 32 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" 33 | integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= 34 | 35 | brace-expansion@^1.1.7: 36 | version "1.1.11" 37 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" 38 | integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== 39 | dependencies: 40 | balanced-match "^1.0.0" 41 | concat-map "0.0.1" 42 | 43 | call-me-maybe@^1.0.1: 44 | version "1.0.1" 45 | resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" 46 | integrity sha1-JtII6onje1y95gJQoV8DHBak1ms= 47 | 48 | camelcase@^6.3.0: 49 | version "6.3.0" 50 | resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" 51 | integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== 52 | 53 | commander@^9.0.0: 54 | version "9.0.0" 55 | resolved "https://registry.yarnpkg.com/commander/-/commander-9.0.0.tgz#86d58f24ee98126568936bd1d3574e0308a99a40" 56 | integrity sha512-JJfP2saEKbQqvW+FI93OYUB4ByV5cizMpFMiiJI8xDbBvQvSkIk0VvQdn1CZ8mqAO8Loq2h0gYTYtDFUZUeERw== 57 | 58 | concat-map@0.0.1: 59 | version "0.0.1" 60 | resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" 61 | integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= 62 | 63 | fs.realpath@^1.0.0: 64 | version "1.0.0" 65 | resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" 66 | integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= 67 | 68 | glob@^7.1.3: 69 | version "7.1.6" 70 | resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" 71 | integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== 72 | dependencies: 73 | fs.realpath "^1.0.0" 74 | inflight "^1.0.4" 75 | inherits "2" 76 | minimatch "^3.0.4" 77 | once "^1.3.0" 78 | path-is-absolute "^1.0.0" 79 | 80 | handlebars@^4.7.7: 81 | version "4.7.7" 82 | resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" 83 | integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== 84 | dependencies: 85 | minimist "^1.2.5" 86 | neo-async "^2.6.0" 87 | source-map "^0.6.1" 88 | wordwrap "^1.0.0" 89 | optionalDependencies: 90 | uglify-js "^3.1.4" 91 | 92 | inflight@^1.0.4: 93 | version "1.0.6" 94 | resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" 95 | integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= 96 | dependencies: 97 | once "^1.3.0" 98 | wrappy "1" 99 | 100 | inherits@2: 101 | version "2.0.4" 102 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 103 | integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 104 | 105 | js-yaml@^4.1.0: 106 | version "4.1.0" 107 | resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" 108 | integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== 109 | dependencies: 110 | argparse "^2.0.1" 111 | 112 | json-schema-ref-parser@^9.0.9: 113 | version "9.0.9" 114 | resolved "https://registry.yarnpkg.com/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz#66ea538e7450b12af342fa3d5b8458bc1e1e013f" 115 | integrity sha512-qcP2lmGy+JUoQJ4DOQeLaZDqH9qSkeGCK3suKWxJXS82dg728Mn3j97azDMaOUmJAN4uCq91LdPx4K7E8F1a7Q== 116 | dependencies: 117 | "@apidevtools/json-schema-ref-parser" "9.0.9" 118 | 119 | k6@^0.0.0: 120 | version "0.0.0" 121 | resolved "https://registry.yarnpkg.com/k6/-/k6-0.0.0.tgz#8c923200be0a68c578e8f5a32be96b1d8065cc3b" 122 | integrity sha1-jJIyAL4KaMV46PWjK+lrHYBlzDs= 123 | 124 | minimatch@^3.0.4: 125 | version "3.0.4" 126 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" 127 | integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== 128 | dependencies: 129 | brace-expansion "^1.1.7" 130 | 131 | minimist@^1.2.5: 132 | version "1.2.5" 133 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" 134 | integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== 135 | 136 | neo-async@^2.6.0: 137 | version "2.6.2" 138 | resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" 139 | integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== 140 | 141 | once@^1.3.0: 142 | version "1.4.0" 143 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 144 | integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= 145 | dependencies: 146 | wrappy "1" 147 | 148 | openapi-typescript-codegen@^0.20.1: 149 | version "0.20.1" 150 | resolved "https://registry.yarnpkg.com/openapi-typescript-codegen/-/openapi-typescript-codegen-0.20.1.tgz#1845e2944164721725cb43ca0572c0c43e136d58" 151 | integrity sha512-07Fn/es5a3G696tyqkukmgOr/xH3vGnAp32dQO1mb4mSWmIEgtkMtjm+p7htphGhH0jLX1ruPaGfzU+WkdvIGw== 152 | dependencies: 153 | camelcase "^6.3.0" 154 | commander "^9.0.0" 155 | handlebars "^4.7.7" 156 | json-schema-ref-parser "^9.0.9" 157 | 158 | path-is-absolute@^1.0.0: 159 | version "1.0.1" 160 | resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" 161 | integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= 162 | 163 | rimraf@^3.0.2: 164 | version "3.0.2" 165 | resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" 166 | integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== 167 | dependencies: 168 | glob "^7.1.3" 169 | 170 | source-map@^0.6.1: 171 | version "0.6.1" 172 | resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" 173 | integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== 174 | 175 | typescript@^4.6.2: 176 | version "4.6.2" 177 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4" 178 | integrity sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg== 179 | 180 | uglify-js@^3.1.4: 181 | version "3.13.5" 182 | resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.5.tgz#5d71d6dbba64cf441f32929b1efce7365bb4f113" 183 | integrity sha512-xtB8yEqIkn7zmOyS2zUNBsYCBRhDkvlNxMMY2smuJ/qA8NCHeQvKCF3i9Z4k8FJH4+PJvZRtMrPynfZ75+CSZw== 184 | 185 | wordwrap@^1.0.0: 186 | version "1.0.0" 187 | resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" 188 | integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= 189 | 190 | wrappy@1: 191 | version "1.0.2" 192 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 193 | integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= 194 | --------------------------------------------------------------------------------