diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 47411ee..948827d 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -54,35 +54,35 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - # docker: - # name: Publish Docker Image - # needs: [ build-test ] - # runs-on: ubuntu-latest - # if: success() && github.ref == 'refs/heads/master' + docker: + name: Publish Docker Image + needs: [ build-test ] + runs-on: ubuntu-latest + if: success() && github.ref == 'refs/heads/master' - # outputs: - # digest: ${{ steps.docker_build.outputs.digest }} + outputs: + digest: ${{ steps.docker_build.outputs.digest }} - # steps: - # - name: Set up QEMU - # uses: docker/setup-qemu-action@v2 + steps: + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 - # - name: Set up Docker Buildx - # uses: docker/setup-buildx-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 - # - name: Login to DockerHub - # uses: docker/login-action@v2 - # with: - # username: ${{ secrets.DOCKERHUB_USERNAME }} - # password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - # - name: Build and push - # id: docker_build - # uses: docker/build-push-action@v3 - # with: - # push: true - # platforms: linux/amd64,linux/arm64 - # tags: trackerforce/switcher-resolver-node:latest + - name: Build and push + id: docker_build + uses: docker/build-push-action@v3 + with: + push: true + platforms: linux/amd64,linux/arm64 + tags: trackerforce/switcher-resolver-node:latest # update-kustomize: # name: Deploy diff --git a/README.md b/README.md index 80fd37a..2d49e65 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,9 @@ Resolver Node for Component Switchers
+[![Master CI](https://github.com/switcherapi/switcher-resolver-node/actions/workflows/master.yml/badge.svg?branch=master)](https://github.com/switcherapi/switcher-resolver-node/actions/workflows/master.yml) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=switcherapi_switcher-resolver-node&metric=alert_status)](https://sonarcloud.io/dashboard?id=switcherapi_switcher-resolver-node) +[![Known Vulnerabilities](https://snyk.io/test/github/switcherapi/switcher-resolver-node/badge.svg)](https://snyk.io/test/github/switcherapi/switcher-resolver-node) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Slack: Switcher-HQ](https://img.shields.io/badge/slack-@switcher/hq-blue.svg?logo=slack)](https://switcher-hq.slack.com/) @@ -18,7 +21,7 @@ Resolver Node for Component Switchers
# About -**Switcher Resolver Node** is the Feature Flag resolver that runs the Switcher evaluation engine. +**Switcher Resolver Node** is the Feature Flag resolver that runs the Switcher evaluation criteria engine. * * * @@ -30,4 +33,4 @@ Resolver Node for Component Switchers
# Quick start Open Swagger UI by accessing the URL: http://localhost:3000/api-docs
-Or use Postman by importing either the OpenAPI json from http://localhost:3000/swagger.json or Postman Collection from "requests/Switcher API*" \ No newline at end of file +Or use Postman by importing either the OpenAPI json from http://localhost:3000/swagger.json or Postman Collection from "requests/Switcher Resolver Node*" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..86d3fbc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3.8' + +networks: + backend: + driver: bridge + +services: + switcher_resolver_node: + image: trackerforce/switcher-resolver-node + container_name: switcher-resolver-node + ports: + - 3001:3001 + networks: + - backend + environment: + - NODE_ENV=development + - PORT=3001 + - ENV=${ENV} + - SSL_KEY=${SSL_KEY} + - SSL_CERT=${SSL_CERT} + + - MONGODB_URI=${MONGODB_URI} + - RESOURCE_SECRET=${RESOURCE_SECRET} + - JWT_SECRET=${JWT_SECRET} + - JWT_CLIENT_TOKEN_EXP_TIME=${JWT_CLIENT_TOKEN_EXP_TIME} + - RELAY_BYPASS_HTTPS=${RELAY_BYPASS_HTTPS} + - RELAY_BYPASS_VERIFICATION=${RELAY_BYPASS_VERIFICATION} + - METRICS_ACTIVATED=${METRICS_ACTIVATED} + - METRICS_MAX_PAGE=${METRICS_MAX_PAGE} + - REGEX_MAX_TIMEOUT=${REGEX_MAX_TIMEOUT} + - REGEX_MAX_BLACLIST=${REGEX_MAX_BLACLIST} + - MAX_REQUEST_PER_MINUTE=${MAX_REQUEST_PER_MINUTE} + + - SWITCHER_API_LOGGER=${SWITCHER_API_LOGGER} + - SWITCHER_API_ENABLE=${SWITCHER_API_ENABLE} + - SWITCHER_API_URL=${SWITCHER_API_URL} + - SWITCHER_API_KEY=${SWITCHER_API_KEY} + - SWITCHER_API_DOMAIN=${SWITCHER_API_DOMAIN} + - SWITCHER_API_ENVIRONMENT=${SWITCHER_API_ENVIRONMENT} \ No newline at end of file diff --git a/package.json b/package.json index 2351630..6d25ae8 100644 --- a/package.json +++ b/package.json @@ -27,21 +27,22 @@ ], "license": "MIT", "dependencies": { - "axios": "^1.6.1", + "axios": "^1.6.2", "bcryptjs": "^2.4.3", "cors": "^2.8.5", "express": "^4.18.2", + "express-basic-auth": "^1.2.1", "express-rate-limit": "^7.1.4", "express-validator": "^7.0.1", "graphql": "^16.8.1", "graphql-http": "^1.22.0", "graphql-tag": "^2.12.6", - "helmet": "^7.0.0", + "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "moment": "^2.29.4", - "mongodb": "^6.2.0", - "mongoose": "^8.0.0", - "pino": "^8.16.1", + "mongodb": "^6.3.0", + "mongoose": "^8.0.1", + "pino": "^8.16.2", "pino-pretty": "^10.2.3", "swagger-ui-express": "^5.0.0", "switcher-client": "^3.2.0", @@ -49,19 +50,19 @@ }, "devDependencies": { "@babel/cli": "^7.23.0", - "@babel/core": "^7.23.2", + "@babel/core": "^7.23.3", "@babel/node": "^7.22.19", - "@babel/preset-env": "^7.23.2", + "@babel/preset-env": "^7.23.3", "@babel/register": "^7.22.15", "babel-jest": "^29.7.0", "babel-polyfill": "^6.26.0", "env-cmd": "^10.1.0", - "eslint": "^8.52.0", + "eslint": "^8.54.0", "jest": "^29.7.0", "jest-sonar-reporter": "^2.0.0", "node-notifier": "^10.0.1", "nodemon": "^3.0.1", - "sinon": "^17.0.0", + "sinon": "^17.0.1", "supertest": "^6.3.3" }, "repository": { diff --git a/requests/Switcher Resolver Node.postman_collection.json b/requests/Switcher Resolver Node.postman_collection.json new file mode 100644 index 0000000..f547735 --- /dev/null +++ b/requests/Switcher Resolver Node.postman_collection.json @@ -0,0 +1,504 @@ +{ + "info": { + "_postman_id": "2987131f-2c31-440d-8a8f-0f43f49483c3", + "name": "Switcher Resolver Node", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "9436108" + }, + "item": [ + { + "name": "REST", + "item": [ + { + "name": "Auth component", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " pm.environment.set('authClientToken', pm.response.json().token)", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "apikey", + "apikey": [ + { + "key": "value", + "value": "{{apiKey}}", + "type": "string" + }, + { + "key": "key", + "value": "switcher-api-key", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"domain\": \"Playground\",\n\t\"component\": \"switcher-playground\",\n\t\"environment\": \"default\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/criteria/auth", + "host": [ + "{{url}}" + ], + "path": [ + "criteria", + "auth" + ], + "query": [ + { + "key": "showReason", + "value": "true", + "disabled": true + }, + { + "key": "showStrategy", + "value": "true", + "disabled": true + }, + { + "key": "bypassMetric", + "value": "true", + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "Resolve criteria - with input", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{authClientToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"entry\": [\n\t\t{\n\t\t\t\"strategy\": \"PAYLOAD_VALIDATION\",\n\t\t\t\"input\": \"{ \\\"status\\\": \\\"ready\\\" }\"\n\t\t}]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/criteria?showReason=true&showStrategy=true&key=MY_SWITCHER", + "host": [ + "{{url}}" + ], + "path": [ + "criteria" + ], + "query": [ + { + "key": "showReason", + "value": "true" + }, + { + "key": "showStrategy", + "value": "true" + }, + { + "key": "bypassMetric", + "value": "true", + "disabled": true + }, + { + "key": "key", + "value": "MY_SWITCHER" + } + ] + } + }, + "response": [] + }, + { + "name": "Resolve criteria", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{authClientToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/criteria?key=MY_SWITCHER", + "host": [ + "{{url}}" + ], + "path": [ + "criteria" + ], + "query": [ + { + "key": "showReason", + "value": "true", + "disabled": true + }, + { + "key": "showStrategy", + "value": "true", + "disabled": true + }, + { + "key": "bypassMetric", + "value": "true", + "disabled": true + }, + { + "key": "key", + "value": "MY_SWITCHER" + } + ] + } + }, + "response": [] + }, + { + "name": "Check Snapshot", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{authClientToken}}", + "type": "string" + } + ] + }, + "method": "GET", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/criteria/snapshot_check/:version", + "host": [ + "{{url}}" + ], + "path": [ + "criteria", + "snapshot_check", + ":version" + ], + "variable": [ + { + "key": "version", + "value": "1588557288037" + } + ] + } + }, + "response": [] + }, + { + "name": "Check Switchers", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{authClientToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "name": "Content-Type", + "type": "text", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"switchers\": [\r\n \"FEATURE2020\",\r\n \"RELAY_1\"\r\n ]\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{url}}/criteria/switchers_check", + "host": [ + "{{url}}" + ], + "path": [ + "criteria", + "switchers_check" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "GraphqQL", + "item": [ + { + "name": "Resolve criteria - complex", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{authClientToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "{\r\n criteria(\r\n key: \"MY_SWITCHER\",\r\n # bypassMetric: false,\r\n entry: [\r\n {\r\n strategy: \"VALUE_VALIDATION\", \r\n input: \"Roger\"\r\n }\r\n ]\r\n ) {\r\n key\r\n activated\r\n response {\r\n result\r\n reason\r\n domain {\r\n name\r\n activated\r\n description\r\n group {\r\n name\r\n activated\r\n description\r\n config {\r\n key\r\n activated\r\n description\r\n strategies {\r\n strategy\r\n activated\r\n operation\r\n values\r\n }\r\n }\r\n }\r\n }\r\n group {\r\n name\r\n activated\r\n description\r\n }\r\n strategies {\r\n strategy\r\n activated\r\n operation\r\n values\r\n }\r\n }\r\n }\r\n}", + "variables": "" + } + }, + "url": { + "raw": "{{url}}/graphql", + "host": [ + "{{url}}" + ], + "path": [ + "graphql" + ] + } + }, + "response": [] + }, + { + "name": "Resolve criteria - simple", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{authClientToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "{\r\n criteria(\r\n key: \"MY_SWITCHER\", \r\n entry: [\r\n {\r\n strategy: \"VALUE_VALIDATION\", \r\n input: \"Roger\"\r\n },\r\n {\r\n strategy: \"NETWORK_VALIDATION\", \r\n input: \"192.168.0.2\"\r\n }\r\n ]\r\n ) {\r\n response {\r\n result\r\n reason\r\n }\r\n }\r\n}", + "variables": "" + } + }, + "url": { + "raw": "{{url}}/graphql", + "host": [ + "{{url}}" + ], + "path": [ + "graphql" + ] + } + }, + "response": [] + }, + { + "name": "Retrieve Snapshot", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{authClientToken}}", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "{\r\n domain(activated: true) {\r\n name\r\n version\r\n description\r\n activated\r\n group(activated: true) {\r\n name\r\n description\r\n activated\r\n config(activated: true) {\r\n key\r\n description\r\n activated\r\n strategies(activated: true) {\r\n strategy\r\n activated\r\n operation\r\n values\r\n }\r\n components\r\n }\r\n }\r\n }\r\n}", + "variables": "" + } + }, + "url": { + "raw": "{{url}}/graphql", + "host": [ + "{{url}}" + ], + "path": [ + "graphql" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "API Check", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer", + "disabled": true + } + ], + "url": { + "raw": "{{url}}/check", + "host": [ + "{{url}}" + ], + "path": [ + "check" + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{authToken}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ] +} \ No newline at end of file diff --git a/src/api-docs/paths/common.js b/src/api-docs/paths/common.js new file mode 100644 index 0000000..39ac5e8 --- /dev/null +++ b/src/api-docs/paths/common.js @@ -0,0 +1,28 @@ +export const commonSchemaContent = (ref) => ({ + 'application/json': { + schema: { + $ref: `#/components/schemas/${ref}` + } + } +}); + +export const commonArraySchemaContent = (ref) => ({ + 'application/json': { + schema: { + type: 'array', + items: { + $ref: `#/components/schemas/${ref}` + } + } + } +}); + +export const commonOneOfSchemaContent = (refs) => ({ + 'application/json': { + schema: { + oneOf: refs.map((ref) => ({ + $ref: `#/components/schemas/${ref}` + })) + } + } +}); \ No newline at end of file diff --git a/src/api-docs/paths/path-client.js b/src/api-docs/paths/path-client.js new file mode 100644 index 0000000..66b9e08 --- /dev/null +++ b/src/api-docs/paths/path-client.js @@ -0,0 +1,201 @@ +import { StrategiesType } from '../../models/config-strategy'; +import { pathParameter, queryParameter } from '../schemas/common'; +import configStrategy from '../schemas/config-strategy'; + +export default { + '/criteria': { + post: { + tags: ['Client API'], + description: 'Execute criteria query against the API settings', + security: [{ appAuth: [] }], + parameters: [ + queryParameter('key', 'string', 'Switcher Key', true), + queryParameter('showReason', 'boolean', 'Show criteria execution reason (default: true)', false), + queryParameter('showStrategy', 'boolean', 'Show criteria execution strategy (default: true)', false), + queryParameter('bypassMetric', 'boolean', 'Bypass metric check (default: true)', false) + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + entry: { + type: 'array', + items: { + type: 'object', + properties: { + strategy: { + type: 'string', + enum: Object.values(StrategiesType) + }, + input: { + type: 'string' + } + } + } + } + } + } + } + } + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + result: { + type: 'boolean' + }, + reason: { + type: 'string' + }, + strategies: { + type: 'array', + items: configStrategy + } + } + } + } + } + } + } + } + }, + '/criteria/snapshot_check/:version': { + get: { + tags: ['Client API'], + description: 'Check if snapshot version is up to date', + security: [{ appAuth: [] }], + parameters: [ + pathParameter('version', 'string', 'Snapshot version', true) + ], + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { + type: 'boolean', + description: 'true if snapshot version is up to date' + } + } + } + } + } + } + } + } + }, + '/criteria/switchers_check': { + post: { + tags: ['Client API'], + description: 'Check if switcher keys are valid', + security: [{ appAuth: [] }], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + switchers: { + type: 'array', + items: { + type: 'string' + } + } + } + } + } + } + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + not_found: { + type: 'array', + items: { + type: 'string' + } + } + } + } + } + } + } + } + } + }, + '/criteria/auth': { + post: { + tags: ['Client API'], + description: 'Authenticate component', + security: [{ apiKey: [] }], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + domain: { + type: 'string', + description: 'Domain name' + }, + component: { + type: 'string', + description: 'Component name' + }, + enviroment: { + type: 'string', + description: 'Enviroment name' + } + } + } + } + } + }, + responses: { + 200: { + description: 'Success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + token: { + type: 'string', + description: 'Authentication token' + }, + exp: { + type: 'number', + description: 'Expiration time' + } + } + } + } + } + } + } + } + } +}; + + + + + + + + diff --git a/src/api-docs/schemas/common.js b/src/api-docs/schemas/common.js new file mode 100644 index 0000000..9295121 --- /dev/null +++ b/src/api-docs/schemas/common.js @@ -0,0 +1,19 @@ +export const queryParameter = (name, description, required, type) => ({ + in: 'query', + name, + description, + required, + schema: { + type: type || 'string' + } +}); + +export const pathParameter = (name, description, required) => ({ + in: 'path', + name, + description, + required, + schema: { + type: 'string' + } +}); diff --git a/src/api-docs/schemas/config-strategy.js b/src/api-docs/schemas/config-strategy.js new file mode 100644 index 0000000..076e81b --- /dev/null +++ b/src/api-docs/schemas/config-strategy.js @@ -0,0 +1,72 @@ +import { OperationsType, StrategiesType } from '../../models/config-strategy'; + +export const configStrategy = { + type: 'object', + properties: { + _id: { + type: 'string', + description: 'The unique identifier of the config strategy' + }, + description: { + type: 'string', + description: 'The description of the config strategy' + }, + activated: { + type: 'object', + additionalProperties: { + type: 'boolean', + description: 'The environment status' + } + }, + strategy: { + type: 'string', + enum: Object.values(StrategiesType) + }, + values: [{ + type: 'string' + }], + operation: { + type: 'string', + enum: Object.values(OperationsType) + }, + config: { + type: 'string', + description: 'The config ID parent of the config strategy', + format: 'uuid' + }, + domain: { + type: 'string', + description: 'The domain ID parent of the config strategy', + format: 'uuid' + }, + owner: { + type: 'uuid', + description: 'The owner id of the config strategy' + }, + admin: { + type: 'object', + properties: { + _id: { + type: 'uuid', + description: 'The unique identifier of the admin' + }, + name: { + type: 'string', + description: 'The name of the admin who created the config strategy' + } + } + }, + createdAt: { + type: 'string', + description: 'The date when the config strategy was created' + }, + updatedAt: { + type: 'string', + description: 'The date when the config strategy was updated' + } + } +}; + +export default { + ConfigStrategy: configStrategy +}; \ No newline at end of file diff --git a/src/api-docs/swagger-document.js b/src/api-docs/swagger-document.js new file mode 100644 index 0000000..b7457c6 --- /dev/null +++ b/src/api-docs/swagger-document.js @@ -0,0 +1,43 @@ +import pathClient from './paths/path-client'; + +import { commonSchema } from './schemas/common'; +import configStrategySchema from './schemas/config-strategy'; +import info from './swagger-info'; + +export default { + openapi: '3.0.1', + info, + servers: [ + { + url: 'http://localhost:3000', + description: 'Local' + }, + { + url: 'https://api.switcherapi.com', + description: 'Cloud API' + } + ], + consumes: ['application/json'], + produces: ['application/json'], + components: { + securitySchemes: { + appAuth: { + type: 'http', + scheme: 'bearer', + name: 'JWT' + }, + apiKey: { + type: 'apiKey', + in: 'header', + name: 'switcher-api-key' + } + }, + schemas: { + ...commonSchema, + ...configStrategySchema + } + }, + paths: { + ...pathClient + } +}; \ No newline at end of file diff --git a/src/api-docs/swagger-info.js b/src/api-docs/swagger-info.js new file mode 100644 index 0000000..fcbbf3d --- /dev/null +++ b/src/api-docs/swagger-info.js @@ -0,0 +1,14 @@ +export default { + title: 'Switcher Resolver Node', + version: 'v1.0.0', + description: 'Resolver Node for Component Switchers.', + contact: { + name: 'Roger Floriano (petruki)', + email: 'switcher.project@gmail.com', + url: 'https://github.com/petruki' + }, + license: { + name: 'MIT', + url: 'https://github.com/switcherapi/switcher-resolver-node/blob/master/LICENSE' + } +}; \ No newline at end of file diff --git a/src/app.js b/src/app.js index 6274186..f9e30b6 100644 --- a/src/app.js +++ b/src/app.js @@ -1,4 +1,5 @@ import express from 'express'; +import swaggerUi from 'swagger-ui-express'; import { createHandler } from 'graphql-http/lib/use/express'; import cors from 'cors'; import helmet from 'helmet'; @@ -6,9 +7,10 @@ import helmet from 'helmet'; require('./db/mongoose'); import mongoose from 'mongoose'; +import swaggerDocument from './api-docs/swagger-document'; import clientApiRouter from './routers/client-api'; import schema from './client/schema'; -import { appAuth } from './middleware/auth'; +import { appAuth, resourcesAuth } from './middleware/auth'; import { clientLimiter, defaultLimiter } from './middleware/limiter'; import { createServer } from './app-server'; @@ -41,6 +43,15 @@ app.use('/graphql', appAuth, clientLimiter, handler); * API Docs and Health Check */ +app.use('/api-docs', resourcesAuth(), + swaggerUi.serve, + swaggerUi.setup(swaggerDocument) +); + +app.get('/swagger.json', resourcesAuth(), (_req, res) => { + res.status(200).send(swaggerDocument); +}); + app.get('/check', defaultLimiter, (req, res) => { const showDetails = req.query.details === '1'; const response = { diff --git a/src/client/configuration-type.js b/src/client/configuration-type.js index 673178d..0d4106a 100644 --- a/src/client/configuration-type.js +++ b/src/client/configuration-type.js @@ -86,11 +86,7 @@ export const configType = new GraphQLObjectType({ type: GraphQLString }, activated: { - type: GraphQLBoolean, - resolve: (source, _args, { environment }) => { - return source.activated[`${environment}`] === undefined ? - source.activated[`${EnvType.DEFAULT}`] : source.activated[`${environment}`]; - } + type: GraphQLBoolean } }, resolve: async (source, { _id, strategy, operation, activated }, context) => { @@ -141,11 +137,7 @@ export const groupConfigType = new GraphQLObjectType({ type: GraphQLString }, activated: { - type: GraphQLBoolean, - resolve: (source, _args, { environment }) => { - return source.activated[`${environment}`] === undefined ? - source.activated[`${EnvType.DEFAULT}`] : source.activated[`${environment}`]; - } + type: GraphQLBoolean } }, resolve: async (source, { _id, key, activated }, context) => { @@ -210,11 +202,7 @@ export const domainType = new GraphQLObjectType({ type: GraphQLString }, activated: { - type: GraphQLBoolean, - resolve: (source, _args, { environment }) => { - return source.activated[`${environment}`] === undefined ? - source.activated[`${EnvType.DEFAULT}`] : source.activated[`${environment}`]; - } + type: GraphQLBoolean } }, resolve: async (source, { _id, name, activated }, context) => { diff --git a/src/client/resolvers.js b/src/client/resolvers.js index 929cf0b..0ab1f2c 100644 --- a/src/client/resolvers.js +++ b/src/client/resolvers.js @@ -74,21 +74,8 @@ export async function resolveGroupConfig(source, _id, name, activated, context) return resolveComponentsFirst(source, context, groups); } -export async function resolveDomain(_id, name, activated, context) { - const args = {}; - - if (_id) { - args._id = _id; - } else if (context.domain) { - args._id = context.domain; - } - - if (name && context.admin) { - args.name = name; - args.owner = context.admin._id; - } - - let domain = await Domain.findOne({ ...args }).lean().exec(); +export async function resolveDomain(activated, context) { + let domain = await Domain.findOne({ _id: context.domain }).lean().exec(); if (activated !== undefined) { if (domain.activated[context.environment] !== activated) { return null; diff --git a/src/client/schema.js b/src/client/schema.js index 1bc4db7..7545982 100644 --- a/src/client/schema.js +++ b/src/client/schema.js @@ -1,6 +1,5 @@ import { GraphQLSchema, GraphQLObjectType, GraphQLString, GraphQLList, GraphQLBoolean, GraphQLNonNull } from 'graphql'; import { domainType } from './configuration-type'; -import { EnvType } from '../models/environment'; import { strategyInputType, criteriaType } from './criteria-type'; import { resolveConfigByKey, resolveDomain } from './resolvers'; @@ -36,11 +35,7 @@ const queryType = new GraphQLObjectType({ type: GraphQLString }, activated: { - type: GraphQLBoolean, - resolve: (source, _args, { environment }) => { - return source.activated.get(environment) === undefined ? - source.activated.get(EnvType.DEFAULT) : source.activated.get(environment); - } + type: GraphQLBoolean }, environment: { type: GraphQLString @@ -49,10 +44,10 @@ const queryType = new GraphQLObjectType({ type: GraphQLString } }, - resolve: async (_source, { _id, name, activated, environment, _component }, context) => { + resolve: async (_source, { activated, environment, _component }, context) => { if (environment) context.environment = environment; if (_component) context._component = _component; - return resolveDomain(_id, name, activated, context); + return resolveDomain(activated, context); } } } diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 9581b7c..4843f92 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,9 +1,19 @@ +import basicAuth from 'express-basic-auth'; import jwt from 'jsonwebtoken'; import { getComponentById } from '../services/component'; import { responseExceptionSilent } from '../exceptions'; import Component from '../models/component'; import { getRateLimit } from '../external/switcher-api-facade'; +export function resourcesAuth() { + return basicAuth({ + users: { + admin: process.env.RESOURCE_SECRET || 'admin', + }, + challenge: true, + }); +} + export async function appAuth(req, res, next) { try { const token = req.header('Authorization').replace('Bearer ', ''); diff --git a/tests/app.test.js b/tests/app.test.js new file mode 100644 index 0000000..d5e1414 --- /dev/null +++ b/tests/app.test.js @@ -0,0 +1,38 @@ +import mongoose from 'mongoose'; +import app from '../src/app'; +import request from 'supertest'; + +afterAll(async () => { + await new Promise(resolve => setTimeout(resolve, 1000)); + await mongoose.disconnect(); +}); + +describe('Testing app [REST] ', () => { + test('APP_SUITE - Should return success on a health check request', async () => { + const req = await request(app) + .get('/check') + .expect(200); + + expect(req.statusCode).toBe(200); + expect(req.body.status).toEqual('UP'); + }); + + test('APP_SUITE - Should return success on a health check request with details', async () => { + const req = await request(app) + .get('/check?details=1') + .expect(200); + + expect(req.statusCode).toBe(200); + expect(req.body.status).toEqual('UP'); + expect(req.body.attributes).toBeDefined(); + }); + + test('APP_SUITE - Should return 404 - Operation not found', async () => { + const req = await request(app) + .get('/not-found') + .expect(404); + + expect(req.statusCode).toBe(404); + expect(req.body.error).toEqual('Operation not found'); + }); +});