├── .gitattributes ├── .npmignore ├── generators ├── app │ ├── constants.js │ ├── templates │ │ └── infrastructure │ │ │ ├── .husky │ │ │ ├── pre-commit │ │ │ └── _ │ │ │ │ └── husky.sh │ │ │ ├── src │ │ │ ├── pubSub │ │ │ │ ├── index.js │ │ │ │ ├── topics.js │ │ │ │ ├── middleware │ │ │ │ │ ├── correlationPublish.js │ │ │ │ │ ├── tenantPublish.js │ │ │ │ │ ├── applyPublishMiddleware.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── tracingPublish.js │ │ │ │ └── pubSub.js │ │ │ ├── messaging │ │ │ │ ├── topics.js │ │ │ │ ├── index.js │ │ │ │ ├── msgHandlers.js │ │ │ │ ├── middleware │ │ │ │ │ ├── correlation │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── multiTenancy │ │ │ │ │ │ ├── tenantIdentification.js │ │ │ │ │ │ └── __tests__ │ │ │ │ │ │ │ └── tenantIdentification.tests.js │ │ │ │ │ └── tracing │ │ │ │ │ │ └── index.js │ │ │ │ └── messagingDataSource.js │ │ │ ├── constants │ │ │ │ ├── permissions.js │ │ │ │ ├── tracingAttributes.js │ │ │ │ ├── identityUserRoles.js │ │ │ │ └── customHttpHeaders.js │ │ │ ├── prisma │ │ │ │ ├── index.js │ │ │ │ ├── index.d.ts │ │ │ │ ├── tenancyExtension.js │ │ │ │ ├── utils.js │ │ │ │ └── client.js │ │ │ ├── subscriptions │ │ │ │ ├── index.js │ │ │ │ ├── middleware │ │ │ │ │ ├── correlation.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── tenantContext.js │ │ │ │ │ └── tracing.js │ │ │ │ └── extensibleSubscription.js │ │ │ ├── middleware │ │ │ │ ├── logger │ │ │ │ │ └── loggingMiddleware.js │ │ │ │ ├── correlation │ │ │ │ │ └── correlationMiddleware.js │ │ │ │ ├── errorHandling │ │ │ │ │ └── errorHandlingMiddleware.js │ │ │ │ ├── permissions │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ └── rules.tests.js │ │ │ │ │ ├── index.js │ │ │ │ │ └── rules.js │ │ │ │ ├── tracing │ │ │ │ │ └── tracingMiddleware.js │ │ │ │ ├── index.js │ │ │ │ ├── tenantIdentification │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ └── tenantIdentification.tests.js │ │ │ │ │ └── index.js │ │ │ │ └── auth │ │ │ │ │ └── auth.js │ │ │ ├── features │ │ │ │ ├── tenant │ │ │ │ │ ├── schema.graphql │ │ │ │ │ ├── resolvers.js │ │ │ │ │ └── dataSources │ │ │ │ │ │ └── tenantIdentityApi.js │ │ │ │ ├── common │ │ │ │ │ └── rootSchema.graphql │ │ │ │ └── user │ │ │ │ │ ├── schema.graphql │ │ │ │ │ ├── dataSources │ │ │ │ │ └── userApi.js │ │ │ │ │ └── resolvers.js │ │ │ ├── servers │ │ │ │ ├── index.js │ │ │ │ ├── messaging.js │ │ │ │ ├── subscription.js │ │ │ │ └── apollo.js │ │ │ ├── startup │ │ │ │ ├── index.js │ │ │ │ ├── dataSources.js │ │ │ │ ├── schema.js │ │ │ │ ├── logger.js │ │ │ │ └── tracing.js │ │ │ ├── utils │ │ │ │ ├── scalar.js │ │ │ │ ├── noCacheRESTDataSource.js │ │ │ │ ├── pipeline.js │ │ │ │ ├── apiRestDataSource.js │ │ │ │ └── functions.js │ │ │ └── index.js │ │ │ ├── jsconfig.json │ │ │ ├── .prettierrc │ │ │ ├── .vscode │ │ │ ├── settings.json │ │ │ ├── tasks.json │ │ │ └── launch.json │ │ │ ├── .dockerignore │ │ │ ├── .npmignore │ │ │ ├── helm │ │ │ └── gql │ │ │ │ ├── .helmignore │ │ │ │ ├── Chart.yaml │ │ │ │ ├── templates │ │ │ │ ├── service.yaml │ │ │ │ ├── _helpers.tpl │ │ │ │ └── deployment.yaml │ │ │ │ └── values.yaml │ │ │ ├── prisma │ │ │ └── schema.prisma │ │ │ ├── Dockerfile │ │ │ ├── .env.development │ │ │ ├── README.md │ │ │ ├── .gitignore-template │ │ │ ├── .env │ │ │ ├── eslint.config.js │ │ │ └── package.json │ ├── questions.js │ └── index.js ├── __tests__ │ ├── installers.spec.js │ ├── app.spec.js │ └── answers.spec.js └── utils.js ├── .gitignore ├── assets └── img │ ├── appicon.png │ ├── versionWarning.png │ ├── upgrade_project_name.png │ └── upgrade_file_conflicts.png ├── jsconfig.json ├── .prettierrc ├── .yo-rc.json ├── .editorconfig ├── .vscode ├── settings.json └── launch.json ├── .github ├── release-drafter.yml ├── workflows │ ├── release-drafter.yml │ ├── tests.yml │ └── npm-publish.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── LICENSE ├── README.md ├── package.json └── eslint.config.mjs /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .tmp 4 | -------------------------------------------------------------------------------- /generators/app/constants.js: -------------------------------------------------------------------------------- 1 | export const YO_RC_FILE = '.yo-rc.json' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | act 4 | **/__tests__/test-graphql 5 | .tmp* 6 | -------------------------------------------------------------------------------- /assets/img/appicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osstotalsoft/generator-graphql-rocket/HEAD/assets/img/appicon.png -------------------------------------------------------------------------------- /assets/img/versionWarning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osstotalsoft/generator-graphql-rocket/HEAD/assets/img/versionWarning.png -------------------------------------------------------------------------------- /assets/img/upgrade_project_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osstotalsoft/generator-graphql-rocket/HEAD/assets/img/upgrade_project_name.png -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint 5 | npx lint-staged -------------------------------------------------------------------------------- /assets/img/upgrade_file_conflicts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osstotalsoft/generator-graphql-rocket/HEAD/assets/img/upgrade_file_conflicts.png -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES6" 5 | }, 6 | "exclude": ["node_modules"] 7 | } 8 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/pubSub/index.js: -------------------------------------------------------------------------------- 1 | module.exports.topics = require("./topics") 2 | module.exports.pubSub = require("./pubSub") 3 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/messaging/topics.js: -------------------------------------------------------------------------------- 1 | // Event topics that are received from Nats 2 | const topics = { 3 | } 4 | 5 | module.exports = topics; -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 125, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "semi": false, 6 | "trailingComma": "none", 7 | "arrowParens": "avoid" 8 | } 9 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/constants/permissions.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | <%_if(addQuickStart){ _%> 3 | viewDashboard: "VIEW_DASHBOARD" 4 | <%_}_%> 5 | } -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/constants/tracingAttributes.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tenantId: "nbb.tenant_id", 3 | correlationId: "nbb.correlation_id" 4 | }; 5 | -------------------------------------------------------------------------------- /.yo-rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "generator-node": { 3 | "promptValues": { 4 | "authorName": "Totalsoft", 5 | "authorUrl": "https://github.com/osstotalsoft" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES6" 5 | }, 6 | "exclude": ["node_modules"] 7 | } 8 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/prisma/index.js: -------------------------------------------------------------------------------- 1 | const { prisma } = require('./client') 2 | const { prismaPaginated } = require('./utils') 3 | module.exports = { prisma, prismaPaginated } 4 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/subscriptions/index.js: -------------------------------------------------------------------------------- 1 | module.exports.subscribe = require("./extensibleSubscription").subscribe 2 | module.exports.middleware = require("./middleware") 3 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 125, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "semi": false, 6 | "trailingComma": "none", 7 | "arrowParens": "avoid" 8 | } 9 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/constants/identityUserRoles.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | admin: "tenant_admin", 3 | user: "tenant_user"<% if(withMultiTenancy){ %>, 4 | globalAdmin: "global_admin" 5 | <%}%> 6 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.useFlatConfig": true, 3 | "javascript.validate.enable": false, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "cSpell.words": ["OTEL", "totalsoft", "Uncapitalize", "yorc"] 6 | } 7 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/middleware/logger/loggingMiddleware.js: -------------------------------------------------------------------------------- 1 | const loggingMiddleware = async (ctx, next) => { 2 | if (!ctx.logger) ctx.logger = require('../../startup/logger') 3 | await next() 4 | } 5 | 6 | module.exports = loggingMiddleware -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/pubSub/topics.js: -------------------------------------------------------------------------------- 1 | // Inner GraphQL event topics, events that are published in Redis 2 | const topics = { 3 | <%_if(addQuickStart){ _%> 4 | USER_CHANGED: "GQL.Notification.UserChanged" 5 | <%_}_%> 6 | } 7 | 8 | module.exports = topics; -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "javascript.validate.enable": false, 3 | "eslint.useFlatConfig": true, 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "[prisma]": { 6 | "editor.defaultFormatter": "Prisma.prisma" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/messaging/index.js: -------------------------------------------------------------------------------- 1 | module.exports.topics = require("./topics") 2 | module.exports.MessagingDataSource = require("./messagingDataSource") 3 | module.exports.msgHandlers = require("./msgHandlers") 4 | module.exports.middleware = require("./middleware") 5 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/messaging/msgHandlers.js: -------------------------------------------------------------------------------- 1 | const messagingHost = require("@totalsoft/messaging-host") 2 | const someOtherMessageHandlers = {} 3 | 4 | const handlers = messagingHost.dispatcher.mergeHandlers([someOtherMessageHandlers]) 5 | 6 | module.exports = handlers -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/constants/customHttpHeaders.js: -------------------------------------------------------------------------------- 1 | const customHttpHeaders = { 2 | <%_ if(withMultiTenancy) {_%> 3 | TenantId: "TenantId", 4 | <%_}_%> 5 | UserId: "user-id", 6 | UserPassport: "user-passport" 7 | }; 8 | 9 | module.exports = { ...customHttpHeaders }; 10 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.vscode 2 | **/.git 3 | **/.gitignore 4 | **/Dockerfile* 5 | **/node_modules 6 | **/iisnode 7 | LICENSE 8 | README.md 9 | .dockerignore 10 | .eslintrc.json 11 | .husky 12 | .prettierrc 13 | coverage 14 | GitVersion.yml 15 | __mocks__ 16 | helm 17 | .env.development 18 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/.npmignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | /.vs -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/subscriptions/middleware/correlation.js: -------------------------------------------------------------------------------- 1 | const { correlationManager } = require("@totalsoft/correlation"); 2 | 3 | const correlation = async (ctx, next) => { 4 | return await correlationManager.useCorrelationId(ctx.message.headers?.pubSubCorrelationId, next) 5 | }; 6 | 7 | module.exports = { correlation }; 8 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: v$NEXT_PATCH_VERSION 2 | tag-template: v$NEXT_PATCH_VERSION 3 | categories: 4 | - title: 🚀 Features 5 | label: feature 6 | - title: 🐛 Bug Fixes 7 | label: fix 8 | - title: 🛠️ Maintenance 9 | label: chore 10 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 11 | template: | 12 | ## Changes 13 | $CHANGES -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/features/tenant/schema.graphql: -------------------------------------------------------------------------------- 1 | type Tenant { 2 | id: ID! 3 | name: String! 4 | code: String! 5 | } 6 | 7 | type IdentityTenant { 8 | id: ID! 9 | name: String 10 | code: String 11 | tier: String 12 | isActive: Boolean 13 | tenant: Tenant 14 | } 15 | extend type Query { 16 | myTenants: [IdentityTenant!]! 17 | } 18 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/pubSub/middleware/correlationPublish.js: -------------------------------------------------------------------------------- 1 | const { correlationManager } = require('@totalsoft/correlation') 2 | 3 | const correlationPublish = async (ctx, next) => { 4 | if (ctx.message.headers) ctx.message.headers.pubSubCorrelationId = correlationManager.getCorrelationId() 5 | 6 | return await next() 7 | } 8 | 9 | module.exports = { correlationPublish } 10 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/pubSub/middleware/tenantPublish.js: -------------------------------------------------------------------------------- 1 | const { tenantContextAccessor } = require('@totalsoft/multitenancy-core') 2 | 3 | const tenantPublish = async (ctx, next) => { 4 | const tenantContext = tenantContextAccessor.getTenantContext() 5 | ctx.message.headers.pubSubTenantId = tenantContext?.tenant?.id 6 | 7 | return await next() 8 | } 9 | 10 | module.exports = { tenantPublish } 11 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/messaging/middleware/correlation/index.js: -------------------------------------------------------------------------------- 1 | const { correlationManager } = require('@totalsoft/correlation') 2 | const { envelope } = require('@totalsoft/message-bus') 3 | 4 | const correlation = () => async (ctx, next) => { 5 | const correlationId = envelope.getCorrelationId(ctx.received.msg) 6 | await correlationManager.useCorrelationId(correlationId, next) 7 | }; 8 | 9 | module.exports = correlation; 10 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/middleware/correlation/correlationMiddleware.js: -------------------------------------------------------------------------------- 1 | const { correlationManager } = require("@totalsoft/correlation"); 2 | const CORRELATION_ID = "x-correlation-id"; 3 | 4 | const correlationMiddleware = () => async (ctx, next) => { 5 | const correlationId = ctx.req.headers[CORRELATION_ID]; 6 | await correlationManager.useCorrelationId(correlationId, next) 7 | } 8 | 9 | module.exports = correlationMiddleware; 10 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | inputs: 9 | 10 | jobs: 11 | update_release_draft: 12 | runs-on: ubuntu-latest 13 | steps: 14 | # Drafts your next Release notes as Pull Requests are merged into "master" 15 | - uses: release-drafter/release-drafter@v6 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/servers/index.js: -------------------------------------------------------------------------------- 1 | const { startApolloServer } = require('./apollo') 2 | <%_ if(addMessaging) { _%> 3 | const startMsgHost = require('./messaging') 4 | <%_ } _%> 5 | <%_ if(addSubscriptions){ _%> 6 | const startSubscriptionServer = require('./subscription') 7 | <%_ } _%> 8 | 9 | module.exports = { startApolloServer<% if(addMessaging) { %>, startMsgHost <% } %> <% if(addSubscriptions){ %>, startSubscriptionServer <% } %>} 10 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/startup/index.js: -------------------------------------------------------------------------------- 1 | <%_ if(addTracing) {_%> 2 | const tracer = require("./tracing") // should be imported first to patch other modules 3 | <%_}_%> 4 | const { getDataSources } = require("./dataSources"); 5 | const schema = require("./schema"); 6 | const logger = require("./logger"); 7 | 8 | module.exports = { 9 | schema, 10 | getDataSources, 11 | <%_ if(addTracing) {_%> 12 | tracer, 13 | <%_}_%> 14 | logger 15 | }; 16 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/messaging/middleware/index.js: -------------------------------------------------------------------------------- 1 | const correlation = require("./correlation"); 2 | <%_ if(withMultiTenancy){ _%> 3 | const tenantIdentification = require("./multiTenancy/tenantIdentification") 4 | <%_}_%> 5 | <%_ if(addTracing){ _%> 6 | const { tracing, tracingPublish } = require("./tracing") 7 | <%_}_%> 8 | 9 | module.exports = { <% if(withMultiTenancy){ %>tenantIdentification, <%}%>correlation,<% if(addTracing){ %> tracing, tracingPublish <%}%>} 10 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "bridge-to-kubernetes.resource", 6 | "type": "bridge-to-kubernetes.resource", 7 | "resource": "[myServiceName]", 8 | "resourceType": "service", 9 | "ports": [4000], 10 | "targetCluster": "[myCluster]", 11 | "targetNamespace": "[myNamespace]", 12 | "useKubernetesServiceEnvironmentVariables": false 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/helm/gql/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | .vscode/ 23 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/subscriptions/middleware/index.js: -------------------------------------------------------------------------------- 1 | const { correlation } = require('./correlation') 2 | <%_ if(addTracing) {_%> 3 | const { tracing } = require('./tracing') 4 | <%_}_%> 5 | <%_ if(withMultiTenancy) {_%> 6 | const { tenantContext } = require('./tenantContext') 7 | <%_}_%> 8 | 9 | module.exports = { 10 | correlation, 11 | <%_ if(addTracing) {_%> 12 | tracing, 13 | <%_}_%> 14 | <%_ if(withMultiTenancy) {_%> 15 | tenantContext 16 | <%_}_%> 17 | } 18 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/utils/scalar.js: -------------------------------------------------------------------------------- 1 | const { GraphQLScalarType } = require('graphql'); 2 | 3 | const DateTimeType = new GraphQLScalarType({ 4 | name: 'DateTime', 5 | description: 'js date time', 6 | serialize(value) { 7 | return new Date(value); 8 | }, 9 | parseValue(value) { 10 | return new Date(value); 11 | }, 12 | parseLiteral(value) { 13 | return new Date(value); 14 | } 15 | }); 16 | 17 | module.exports = DateTimeType; -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/middleware/errorHandling/errorHandlingMiddleware.js: -------------------------------------------------------------------------------- 1 | const errorHandlingMiddleware = () => async (ctx, next) => { 2 | try { 3 | await next(); 4 | } catch (err) { 5 | 6 | ctx.logger.error(err, `error from ${ctx.req?.url}`) 7 | 8 | // will only respond with JSON 9 | ctx.status = err.statusCode || err.status || 500; 10 | ctx.body = { 11 | type: err.type, 12 | status: ctx.status, 13 | message: err.message, 14 | detail: err.stack 15 | }; 16 | } 17 | }; 18 | 19 | module.exports = errorHandlingMiddleware 20 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/pubSub/middleware/applyPublishMiddleware.js: -------------------------------------------------------------------------------- 1 | const { pipelineBuilder } = require('../../utils/pipeline') 2 | 3 | function applyPublishMiddleware(pubSub, ...middleware) { 4 | const oldPublish = pubSub.publish.bind(pubSub) 5 | const publishPipeline = pipelineBuilder() 6 | .use(...middleware) 7 | .build() 8 | 9 | pubSub.publish = function (triggerName, payload) { 10 | return publishPipeline({ message: payload, clientTopic: triggerName }, () => oldPublish(triggerName, payload)) 11 | } 12 | 13 | return pubSub 14 | } 15 | 16 | module.exports = applyPublishMiddleware 17 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | <%_ if(addTracing){ _%> 4 | previewFeatures = ["tracing"] 5 | <%_}_%> 6 | } 7 | 8 | datasource db { 9 | provider = "sqlserver" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | // Example of generated code 14 | // Property names MUST be camelCase! 15 | // To automatically convert PascalCase to camelCase, use the following npm script: npm run prisma:format 16 | model User { 17 | id String @id(map: "PK_User") @map("Id") @db.UniqueIdentifier 18 | name String @map("Name") @db.NVarChar(128) 19 | } 20 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/pubSub/middleware/index.js: -------------------------------------------------------------------------------- 1 | const applyPublishMiddleware = require ('./applyPublishMiddleware') 2 | const { correlationPublish } = require('./correlationPublish') 3 | <%_ if(addTracing) {_%> 4 | const { tracingPublish } = require('./tracingPublish') 5 | <%_}_%> 6 | <%_ if(withMultiTenancy) {_%> 7 | const { tenantPublish } = require('./tenantPublish') 8 | <%_}_%> 9 | 10 | module.exports = { 11 | applyPublishMiddleware, 12 | correlationPublish, 13 | <%_ if(withMultiTenancy) {_%> 14 | tenantPublish, 15 | <%_}_%> 16 | <%_ if(addTracing) {_%> 17 | tracingPublish 18 | <%_}_%> 19 | } 20 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/startup/dataSources.js: -------------------------------------------------------------------------------- 1 | <%_if(addQuickStart){ _%> 2 | const UserApi = require('../features/user/dataSources/userApi'); 3 | <%_ if(withMultiTenancy){ _%> 4 | const TenantIdentityApi = require('../features/tenant/dataSources/tenantIdentityApi'); 5 | <%_}_%> 6 | <%_}_%> 7 | 8 | module.exports.getDataSources = context => ({ 9 | // Instantiate your data sources here. e.g.: userApi: new UserApi(context) 10 | <%_if(addQuickStart){ _%> 11 | userApi: new UserApi(context) 12 | <%_ if(withMultiTenancy){ _%>, 13 | tenantIdentityApi: new TenantIdentityApi(context) 14 | <%_}_%> 15 | <%_}_%> 16 | }) 17 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/subscriptions/middleware/tenantContext.js: -------------------------------------------------------------------------------- 1 | const { tenantContextAccessor } = require("@totalsoft/multitenancy-core"); 2 | const isMultiTenant = JSON.parse(process.env.IS_MULTITENANT); 3 | 4 | const tenantContext = async (ctx, next) => { 5 | if (!isMultiTenant) { 6 | return await next(); 7 | } 8 | 9 | const tenant = ctx.context?.tenant; 10 | if (!tenant?.id) { 11 | throw new Error(`Tenant not configured on ws connect!`); 12 | } 13 | 14 | const tenantContext = { tenant }; 15 | 16 | return await tenantContextAccessor.useTenantContext(tenantContext, next); 17 | }; 18 | 19 | module.exports = { tenantContext }; 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behaviour** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/features/tenant/resolvers.js: -------------------------------------------------------------------------------- 1 | const { tenantService } = require('@totalsoft/multitenancy-core') 2 | const isMultiTenant = JSON.parse(process.env.IS_MULTITENANT || 'false') 3 | 4 | const tenantResolvers = { 5 | Query: { 6 | myTenants: async (_parent, _params, { dataSources }) => { 7 | if (!isMultiTenant) 8 | return [] 9 | 10 | const tenants = await dataSources.tenantIdentityApi.getTenants() 11 | return tenants 12 | } 13 | }, 14 | IdentityTenant: { 15 | tenant: async ({ tenantId }) => await tenantService.getTenantFromId(tenantId) 16 | } 17 | } 18 | 19 | module.exports = tenantResolvers; 20 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/utils/noCacheRESTDataSource.js: -------------------------------------------------------------------------------- 1 | const { RESTDataSource } = require('@apollo/datasource-rest') 2 | class NoCacheRESTDataSource extends RESTDataSource { 3 | 4 | cacheOptionsFor() { 5 | return { 6 | ttl: 0 7 | } 8 | } 9 | 10 | resolveURL(path) { 11 | if (path.startsWith('/')) { 12 | path = path.slice(1) 13 | } 14 | const baseURL = this.baseURL 15 | if (baseURL) { 16 | const normalizedBaseURL = baseURL.endsWith('/') ? baseURL : baseURL.concat('/') 17 | return new URL(path, normalizedBaseURL) 18 | } else { 19 | return new URL(path) 20 | } 21 | } 22 | } 23 | 24 | module.exports = { NoCacheRESTDataSource } 25 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/middleware/permissions/__tests__/rules.tests.js: -------------------------------------------------------------------------------- 1 | const { 2 | isAuthenticated, 3 | isAdmin, 4 | canViewDashboard 5 | } = require('../rules') 6 | const { isRuleFunction } = require('graphql-shield/dist/utils') 7 | 8 | describe('Test permission rules to be valid rule functions', () => { 9 | test('isAuthenticated is valid rule function', () => { 10 | expect(isRuleFunction(isAuthenticated)).toBeTruthy() 11 | }) 12 | 13 | test('isAdmin is valid rule function', () => { 14 | expect(isRuleFunction(isAdmin)).toBeTruthy() 15 | }) 16 | 17 | test('canViewDashboard is valid rule function', () => { 18 | expect(isRuleFunction(canViewDashboard)).toBeTruthy() 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/utils/pipeline.js: -------------------------------------------------------------------------------- 1 | function empty(ctx, next) { 2 | return next(); 3 | } 4 | 5 | function emptyFn() {} 6 | 7 | function concat(middleware, pipeline) { 8 | return (ctx, next) => pipeline(ctx, () => middleware(ctx, next)); 9 | } 10 | 11 | function run(pipeline, ctx) { 12 | return pipeline(ctx, emptyFn); 13 | } 14 | 15 | function pipelineBuilder() { 16 | let pipeline = empty; 17 | 18 | function use(...middleware) { 19 | pipeline = middleware.reduce((p, m) => concat(m, p), pipeline); 20 | return this; 21 | } 22 | 23 | function build() { 24 | return pipeline; 25 | } 26 | 27 | return { use, build }; 28 | } 29 | 30 | module.exports = { empty, concat, run, pipelineBuilder }; 31 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to NPM when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: run tests 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | branches: [master] 12 | 13 | jobs: 14 | tests: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20.x 21 | - name: Install packages 22 | run: npm ci 23 | - name: Test EJS syntax 24 | run: npm run ejslint 25 | - name: Run tests 26 | run: npm run test:coverage 27 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/.husky/_/husky.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | if [ -z "$husky_skip_init" ]; then 3 | debug () { 4 | if [ "$HUSKY_DEBUG" = "1" ]; then 5 | echo "husky (debug) - $1" 6 | fi 7 | } 8 | 9 | readonly hook_name="$(basename "$0")" 10 | debug "starting $hook_name..." 11 | 12 | if [ "$HUSKY" = "0" ]; then 13 | debug "HUSKY env variable is set to 0, skipping hook" 14 | exit 0 15 | fi 16 | 17 | if [ -f ~/.huskyrc ]; then 18 | debug "sourcing ~/.huskyrc" 19 | . ~/.huskyrc 20 | fi 21 | 22 | export readonly husky_skip_init=1 23 | sh -e "$0" "$@" 24 | exitCode="$?" 25 | 26 | if [ $exitCode != 0 ]; then 27 | echo "husky - $hook_name hook exited with code $exitCode (error)" 28 | fi 29 | 30 | exit $exitCode 31 | fi 32 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/messaging/middleware/multiTenancy/tenantIdentification.js: -------------------------------------------------------------------------------- 1 | 2 | const { envelope } = require("@totalsoft/message-bus") 3 | const { tenantService, tenantContextAccessor } = require("@totalsoft/multitenancy-core"); 4 | const isMultiTenant = JSON.parse(process.env.IS_MULTITENANT || 'false') 5 | 6 | const tenantIdentification = () => async (ctx, next) => { 7 | const tenant = isMultiTenant ? await tenantService.getTenantFromId(getTenantIdFromMessage(ctx.received.msg)) : {}; 8 | 9 | await tenantContextAccessor.useTenantContext({ tenant }, next); 10 | } 11 | 12 | function getTenantIdFromMessage(msg) { 13 | const tenantId = envelope.getTenantId(msg) || msg.headers.tid 14 | return tenantId 15 | } 16 | 17 | module.exports = tenantIdentification 18 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/features/common/rootSchema.graphql: -------------------------------------------------------------------------------- 1 | scalar DateTime 2 | scalar Byte 3 | scalar Char 4 | scalar Upload 5 | type Query 6 | type Mutation 7 | <%_ if(addSubscriptions){ _%> 8 | type Subscription 9 | <%_}_%> 10 | 11 | schema { 12 | query: Query 13 | mutation: Mutation 14 | <%_ if(addSubscriptions){ _%> 15 | subscription: Subscription 16 | <%_}_%> 17 | } 18 | 19 | input PagerInput { 20 | afterId: ID 21 | sortBy: String 22 | direction: Int 23 | pageSize: Int 24 | } 25 | 26 | type Page { 27 | afterId: ID 28 | sortBy: String 29 | direction: Int 30 | pageSize: Int 31 | } 32 | 33 | type Pagination { 34 | totalCount: Int 35 | prevPage: Page 36 | nextPage: Page 37 | } 38 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/middleware/tracing/tracingMiddleware.js: -------------------------------------------------------------------------------- 1 | const { correlationManager } = require("@totalsoft/correlation"); 2 | const { trace } = require("@opentelemetry/api"); 3 | const attributeNames = require("../../constants/tracingAttributes"); 4 | <%_ if(withMultiTenancy) {_%> 5 | const { tenantContextAccessor } = require("@totalsoft/multitenancy-core"); 6 | <%_}_%> 7 | 8 | const tracingMiddleWare = () => async (ctx, next) => { 9 | const activeSpan = trace.getActiveSpan(); 10 | activeSpan?.setAttribute(attributeNames.correlationId, correlationManager.getCorrelationId()); 11 | <%_ if(withMultiTenancy) {_%> 12 | activeSpan?.setAttribute(attributeNames.tenantId, tenantContextAccessor.getTenantContext()?.tenant?.id); 13 | <%_}_%> 14 | 15 | await next(); 16 | } 17 | 18 | module.exports = tracingMiddleWare; 19 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/prisma/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, PrismaClient } from '@prisma/client' 2 | 3 | export function prisma(): PrismaClient 4 | 5 | export type PrismaModel = PrismaClient[Uncapitalize] 6 | 7 | type Pager = { 8 | afterId: String 9 | sortBy: String 10 | direction: Int 11 | pageSize: Int 12 | } 13 | 14 | type Page = { 15 | afterId: String 16 | sortBy: String 17 | direction: Int 18 | pageSize: Int 19 | } 20 | 21 | type Pagination = { 22 | totalCount: Int 23 | prevPage: Page 24 | nextPage: Page 25 | } 26 | 27 | type PaginatedResult = { 28 | values: object[] 29 | pagination: Pagination 30 | } 31 | 32 | type PrismaMetadata = ArgumentTypes]['findMany']> 33 | 34 | export function prismaPaginated(prismaModel: PrismaModel, pager: Pager, metadata: PrismaMetadata): Promise 35 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/pubSub/pubSub.js: -------------------------------------------------------------------------------- 1 | const { RedisPubSub } = require('graphql-redis-subscriptions') 2 | const { PubSub } = require('graphql-subscriptions'); 3 | const RedisClient = require('ioredis'); 4 | const { REDIS_DOMAIN_NAME, REDIS_PORT_NUMBER } = process.env; 5 | const { applyPublishMiddleware, correlationPublish<% if(addTracing) {%>, tracingPublish<%}%><% if(withMultiTenancy) {%>, tenantPublish<%}%> } = require('./middleware') 6 | 7 | const options = { 8 | host: REDIS_DOMAIN_NAME, 9 | port: REDIS_PORT_NUMBER 10 | }; 11 | 12 | const pubSub = REDIS_DOMAIN_NAME ? 13 | new RedisPubSub({ 14 | publisher: new RedisClient(options), 15 | subscriber: new RedisClient(options) 16 | }) : 17 | new PubSub() 18 | 19 | module.exports = applyPublishMiddleware(pubSub, correlationPublish<% if(withMultiTenancy) {%>, tenantPublish<%}%><% if(addTracing) {%>, tracingPublish<%}%>); 20 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-slim 2 | 3 | # Create app directory 4 | WORKDIR /usr/src/app 5 | 6 | ARG imageUser=appuser 7 | ARG imageUserGroup=appgroup 8 | ARG imageUserId=1375 9 | ARG imageUserGroupId=1375 10 | 11 | RUN addgroup --system --gid $imageUserGroupId $imageUserGroup && \ 12 | adduser --system --uid $imageUserId --ingroup $imageUserGroup $imageUser 13 | 14 | # Install app dependencies 15 | COPY --chown=$imageUser:$imageUserGroup package.json package-lock.json ./ 16 | 17 | RUN npm install 18 | # If you are building your code for production 19 | # RUN npm install --only=production 20 | 21 | # Bundle app source 22 | COPY --chown=$imageUser:$imageUserGroup . . 23 | 24 | RUN apt update && apt install openssl -y 25 | RUN npx prisma generate --schema=./prisma/schema.prisma 26 | 27 | USER $imageUser 28 | 29 | EXPOSE 4000 30 | 31 | CMD [ "npm", "run", "start:production"] 32 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/features/tenant/dataSources/tenantIdentityApi.js: -------------------------------------------------------------------------------- 1 | const { NoCacheRESTDataSource } = require('../../../utils/noCacheRESTDataSource'); 2 | const { assoc } = require('ramda') 3 | 4 | class TenantIdentityApi extends NoCacheRESTDataSource { 5 | constructor(context) { 6 | super(context); 7 | this.baseURL = `${process.env.IDENTITY_API_URL}`; 8 | this.context = context 9 | } 10 | 11 | cacheKeyFor(request) { 12 | return `${request.url}${this.context.externalUser.id}` 13 | } 14 | 15 | willSendRequest(_path, request) { 16 | request.headers = assoc('Authorization', this.context.token, request.headers) 17 | request.headers = assoc('TenantId', this.context.tenant?.id, request.headers) 18 | } 19 | 20 | async getTenants() { 21 | return await this.get(`HomeRealmDiscovery/tenants`); 22 | } 23 | } 24 | 25 | module.exports = TenantIdentityApi; 26 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/.env.development: -------------------------------------------------------------------------------- 1 | <%_ if(withMultiTenancy){ _%> 2 | MultiTenancy__Defaults__ConnectionStrings__MyDatabase__OtherParams= 3 | MultiTenancy__Defaults__ConnectionStrings__MyDatabase__Server= 4 | MultiTenancy__Tenants__tenant1__ConnectionStrings__MyDatabase__Database= 5 | MultiTenancy__Tenants__tenant1__ConnectionStrings__MyDatabase__UserName= 6 | MultiTenancy__Tenants__tenant1__ConnectionStrings__MyDatabase__Password= 7 | MultiTenancy__Tenants__tenant1__TenantId= 8 | <%_}_%> 9 | 10 | LOG_MIN_LEVEL=debug 11 | LOG_DATABASE="Server=servername,1433;Database=databasName;User Id=user;Password=pass;MultipleActiveResultSets=true;TrustServerCertificate=true" 12 | LOG_DATABASE_MINLEVEL=debug 13 | LOG_DATABASE_ENABLED=false 14 | 15 | <%_ if(addTracing){ _%> 16 | LOG_OPENTELEMETRY_TRACING_ENABLED=false 17 | LOG_OPENTELEMETRY__MINLEVEL=debug 18 | OTEL_TRACING_ENABLED=false 19 | OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 20 | <%_}_%> 21 | 22 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/features/user/schema.graphql: -------------------------------------------------------------------------------- 1 | input UserInputType { 2 | id: Int! 3 | firstName: String 4 | lastName: String 5 | } 6 | 7 | type User { 8 | id: ID! 9 | userName: String 10 | firstName: String 11 | lastName: String 12 | rights: [String!] 13 | } 14 | 15 | input UserFilterInput { 16 | firstName: String 17 | lastName: String 18 | } 19 | 20 | type UserList { 21 | values: [User!]! 22 | pagination(pager: PagerInput!, filters: UserFilterInput): Pagination 23 | } 24 | 25 | extend type Query { 26 | userData(id: ID, externalId: ID): User 27 | userList(pager: PagerInput!, filters: UserFilterInput): UserList! 28 | } 29 | 30 | # Not working! Only for demonstration 31 | extend type Mutation { 32 | updateUser(input: UserInputType!): String 33 | } 34 | 35 | <%_ if(addSubscriptions){ _%> 36 | # Not working! Only for demonstration 37 | extend type Subscription { 38 | userChanged: String 39 | } 40 | <%_}_%> -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/README.md: -------------------------------------------------------------------------------- 1 | ## Prerequisites: 2 | • Visual Studio Code: https://code.visualstudio.com/download
3 | • Git: https://git-scm.com/download/win
4 | • Git Extensions: https://github.com/gitextensions/gitextensions/releases
5 | • Node.js: https://nodejs.org/en/download/ 6 | 7 | ## Clone repositories from: 8 | • [ADD repo link] (GQL) 9 | 10 | ## TSDocumentSigner_GQL 11 | Run the following commands to start the project(s): 12 | ### `npm install` 13 | ### `npm start` 14 | 15 | GQL: http://localhost:4000/ (you can also test endpoints here by accessing the playground and inspect schemas and types)
16 | 17 | ## Available Scripts 18 | 19 | In the project directory, you can run: 20 | 21 | ### `npm start` 22 | 23 | Runs the app in the development mode.
24 | Open [http://localhost:4000](http://localhost:4000) to access the GraphQL playground. 25 | 26 | SQL operations are logged in the console.
27 | You will also see any possible errors in the console. -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/features/user/dataSources/userApi.js: -------------------------------------------------------------------------------- 1 | const { NoCacheRESTDataSource } = require('../../../utils/noCacheRESTDataSource'); 2 | const { assoc } = require('ramda') 3 | 4 | class UserApi extends NoCacheRESTDataSource { 5 | 6 | constructor(context) { 7 | super(context); 8 | this.baseURL = `${process.env.API_URL}user`; 9 | this.context = context 10 | } 11 | 12 | willSendRequest(_path, request) { 13 | request.headers = assoc('Authorization', this.context.token, request.headers) 14 | <%_ if(withMultiTenancy){ _%> 15 | request.headers = assoc('TenantId', this.context.tenantId, request.headers) 16 | <%_}_%> 17 | } 18 | 19 | async getRights() { 20 | const data = await this.get(`rights`); 21 | return data; 22 | } 23 | 24 | async getUserData() { 25 | const data = await this.get(`userData`); 26 | return data; 27 | } 28 | } 29 | 30 | module.exports = UserApi; -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/startup/schema.js: -------------------------------------------------------------------------------- 1 | const { makeExecutableSchema } = require('@graphql-tools/schema'), 2 | { loadFilesSync } = require('@graphql-tools/load-files'), 3 | { mergeResolvers, mergeTypeDefs } = require('@graphql-tools/merge'), 4 | { join } = require('path') 5 | 6 | <%_ if(withRights){ _%> 7 | const { applyMiddleware } = require('graphql-middleware'), 8 | { permissionsMiddleware } = require('../middleware') 9 | <%_}_%> 10 | 11 | 12 | const typeDefs = mergeTypeDefs(loadFilesSync(join(__dirname, '../**/*.graphql'))) 13 | const resolvers = mergeResolvers(loadFilesSync(join(__dirname, '../**/*resolvers.{js,ts}')), { 14 | globOptions: { caseSensitiveMatch: false } 15 | }) 16 | 17 | <%_ if(withRights){ _%> 18 | module.exports = applyMiddleware(makeExecutableSchema({ typeDefs, resolvers }), permissionsMiddleware) 19 | <%_} else { _%> 20 | module.exports = makeExecutableSchema({ typeDefs, resolvers }); 21 | <%_}_%> 22 | module.exports.tests = { typeDefs, resolvers } 23 | 24 | 25 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/middleware/index.js: -------------------------------------------------------------------------------- 1 | const correlationMiddleware = require("./correlation/correlationMiddleware") 2 | const validateToken = require("./auth/auth"); 3 | const errorHandlingMiddleware = require('./errorHandling/errorHandlingMiddleware'); 4 | <%_ if(withMultiTenancy){ _%> 5 | const tenantIdentification = require("./tenantIdentification"); 6 | <%_}_%> 7 | <%_ if(addTracing){ _%> 8 | const tracingMiddleware = require('./tracing/tracingMiddleware'); 9 | <%_}_%> 10 | const loggingMiddleware = require('./logger/loggingMiddleware') 11 | <%_ if(withRights){ _%> 12 | const permissionsMiddleware = require('./permissions') 13 | <%_}_%> 14 | 15 | 16 | module.exports = { 17 | ...validateToken, 18 | <%_ if(withRights){ _%> 19 | ...permissionsMiddleware, 20 | <%_}_%> 21 | <%_ if(withMultiTenancy){ _%> 22 | tenantIdentification, 23 | <%_}_%> 24 | correlationMiddleware, 25 | <%_ if(addTracing){ _%> 26 | tracingMiddleware, 27 | <%_}_%> 28 | errorHandlingMiddleware, 29 | loggingMiddleware 30 | }; 31 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/helm/gql/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: <%= helmChartName %> 3 | description: <%= projectName %> (GQL server) 4 | # A chart can be either an 'application' or a 'library' chart. 5 | # 6 | # Application charts are a collection of templates that can be packaged into versioned archives 7 | # to be deployed. 8 | # 9 | # Library charts provide useful utilities or functions for the chart developer. They're included as 10 | # a dependency of application charts to inject those utilities and functions into the rendering 11 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 12 | type: application 13 | 14 | # This is the chart version. This version number should be incremented each time you make changes 15 | # to the chart and its templates, including the app version. 16 | version: 0.1.0 17 | 18 | # This is the version number of the application being deployed. This version number should be 19 | # incremented each time you make changes to the application. 20 | appVersion: 0.1.0 21 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "yeoman", 11 | "program": "${workspaceRoot}/node_modules/yo/lib/cli.js", 12 | "args": ["./generators/app/index.js"], 13 | "stopOnEntry": false, 14 | "cwd": "${workspaceRoot}", 15 | "console": "integratedTerminal", 16 | "internalConsoleOptions": "neverOpen" 17 | }, 18 | { 19 | "args": ["-u", "tdd", "--timeout", "999999", "--colors", "${workspaceRoot}/generators/__tests__"], 20 | "internalConsoleOptions": "openOnSessionStart", 21 | "name": "Mocha Tests", 22 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 23 | "request": "launch", 24 | "skipFiles": ["/**"], 25 | "type": "node" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 osstotalsoft 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to NPM when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: npm-publish 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20.x 18 | - run: npm ci 19 | - run: npm run test:coverage 20 | 21 | bump-version: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version: 20.x 31 | registry-url: https://registry.npmjs.org/ 32 | scope: '@totalsoft' 33 | - name: Bump version 34 | run: npm version $(git describe --abbrev=0 --tags) --no-git-tag-version 35 | - run: npm publish 36 | env: 37 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/helm/gql/templates/service.yaml: -------------------------------------------------------------------------------- 1 | {{- $current := .Values.gql -}} 2 | {{- $global := .Values.global -}} 3 | {{- if $current.enabled -}} 4 | apiVersion: v1 5 | kind: Service 6 | metadata: 7 | name: {{ include "Gql.fullname" . }} 8 | labels: 9 | app.kubernetes.io/name: {{ include "Gql.fullname" . }} 10 | helm.sh/chart: {{ include "Gql.chart" . }} 11 | app.kubernetes.io/instance: {{ .Release.Name }} 12 | app.kubernetes.io/managed-by: {{ .Release.Service }} 13 | {{- if $global.gateway.enabled }} 14 | api-gateway/oidc.audience: {{ include "Gql.name" . }}{{ $current.nameSuffix }} 15 | api-gateway/resource: {{ include "Gql.fullname" . }}{{ $current.nameSuffix }} 16 | api-gateway/secured: "false" 17 | {{- end }} 18 | spec: 19 | type: {{ $current.service.type }} 20 | ports: 21 | - port: {{ $current.service.port }} 22 | protocol: TCP 23 | targetPort: {{ $current.service.targetPort }} 24 | name: http 25 | {{- if $current.service.nodePort }} 26 | nodePort: {{ $current.service.nodePort }} 27 | {{- end }} 28 | selector: 29 | {{- include "Gql.selectorLabels" . | nindent 4 }} 30 | {{- end -}} 31 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/.gitignore-template: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | /.vs 7 | 8 | # Runtime data 9 | 10 | pids 11 | _.pid 12 | _.seed 13 | \*.pid.lock 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | 21 | coverage 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | 29 | bower_components 30 | 31 | # node-waf configuration 32 | 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | 37 | build/Release 38 | 39 | # Dependency directories 40 | 41 | node_modules/ 42 | jspm_packages/ 43 | iisnode/ 44 | 45 | # TypeScript v1 declaration files 46 | 47 | typings/ 48 | 49 | # Optional npm cache directory 50 | 51 | .npm 52 | 53 | # Optional eslint cache 54 | 55 | .eslintcache 56 | 57 | # Optional REPL history 58 | 59 | .node_repl_history 60 | 61 | # Output of 'npm pack' 62 | 63 | \*.tgz 64 | 65 | # next.js build output 66 | .next 67 | 68 | junit.xml 69 | .eslintcache 70 | 71 | prismaClient 72 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/middleware/permissions/index.js: -------------------------------------------------------------------------------- 1 | <%_if(addQuickStart){ _%> 2 | const { shield, and } = require('graphql-shield') 3 | const { isAuthenticated, isAdmin } = require('./rules') 4 | const { GraphQLError } = require('graphql') 5 | 6 | const permissionsMiddleware = shield({ 7 | User: { 8 | rights: isAuthenticated 9 | }, 10 | Query: { 11 | userList: isAuthenticated 12 | }, 13 | Mutation: { 14 | updateUser: and(isAuthenticated, isAdmin) 15 | } 16 | }, 17 | { 18 | debug: true, 19 | allowExternalErrors: true, 20 | fallbackError: (_thrownThing, _parent, _args, _context, info) => { 21 | return new GraphQLError(`You are not authorized to execute this operation! [operation name: "${info.operation.name.value || ''}, field: ${info.fieldName}"]`, { extensions: { code:'ERR_INTERNAL_SERVER' }} ) 22 | }, 23 | }) 24 | 25 | module.exports = { permissionsMiddleware } 26 | <%_}else{_%> 27 | const { shield } = require('graphql-shield') 28 | // Apply shield rules on your schema (see docs https://github.com/maticzav/graphql-shield) 29 | const permissionsMiddleware = shield({}) 30 | module.exports = { permissionsMiddleware } 31 | <%_}_%> -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/servers/messaging.js: -------------------------------------------------------------------------------- 1 | const { messagingHost, exceptionHandling, SubscriptionOptions, dispatcher } = require("@totalsoft/messaging-host"); 2 | const { msgHandlers, middleware } = require("../messaging"); 3 | const { loggingMiddleware } = require("../middleware"); 4 | const { logger } = require("../startup"); 5 | <%_ if(addTracing){ _%> 6 | const { OTEL_TRACING_ENABLED } = process.env, 7 | tracingEnabled = JSON.parse(OTEL_TRACING_ENABLED) 8 | 9 | const skipMiddleware = (_ctx, next) => next(); 10 | <%_}_%> 11 | 12 | 13 | module.exports = function startMsgHost() { 14 | const msgHost = messagingHost(); 15 | msgHost 16 | .subscribe(Object.keys(msgHandlers), SubscriptionOptions.PUB_SUB) 17 | .use(exceptionHandling()) 18 | .use(middleware.correlation()) 19 | <%_ if(addMessaging && withMultiTenancy) {_%> 20 | .use(middleware.tenantIdentification()) 21 | <%_}_%> 22 | <%_ if(addMessaging && addTracing){ _%> 23 | .use(tracingEnabled ? middleware.tracing() : skipMiddleware) 24 | <%_}_%> 25 | .use(loggingMiddleware) 26 | .use(dispatcher(msgHandlers)) 27 | .start() 28 | .catch((err) => { 29 | logger.error(err) 30 | setImmediate(() => { 31 | throw err 32 | }) 33 | }) 34 | return msgHost; 35 | } 36 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/middleware/tenantIdentification/__tests__/tenantIdentification.tests.js: -------------------------------------------------------------------------------- 1 | jest.mock('jsonwebtoken') 2 | jest.mock('@totalsoft/multitenancy-core') 3 | 4 | const OLD_ENV = process.env 5 | describe('tenant identification tests:', () => { 6 | beforeEach(() => { 7 | jest.resetModules() // Most important - it clears the cache 8 | process.env = { ...process.env, IS_MULTITENANT: 'true' } 9 | }) 10 | 11 | afterAll(() => { 12 | process.env = OLD_ENV // Restore old environment 13 | }) 14 | 15 | it('should identify tenant from jwt token:', async () => { 16 | //arrange 17 | const tenantId = "some-tenant-id"; 18 | const tenantIdentification = require("../index"); 19 | const { tenantService, tenantContextAccessor } = require("@totalsoft/multitenancy-core"); 20 | const jsonwebtoken = require("jsonwebtoken"); 21 | jsonwebtoken.decode = () => ({ tid: tenantId }); 22 | tenantService.getTenantFromId.mockImplementation(tid => Promise.resolve({ id: tid })); 23 | 24 | const ctx = { 25 | request: { 26 | path: "/graphql" 27 | }, 28 | method: "POST", 29 | token: "jwt" 30 | }; 31 | 32 | //act 33 | await tenantIdentification()(ctx, () => Promise.resolve()); 34 | 35 | //assert 36 | expect(tenantContextAccessor.useTenantContext).toHaveBeenCalledWith( 37 | { tenant: expect.objectContaining({ id: tenantId }) }, 38 | expect.anything() 39 | ); 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/utils/apiRestDataSource.js: -------------------------------------------------------------------------------- 1 | const { correlationManager } = require("@totalsoft/correlation"); 2 | const { NoCacheRESTDataSource } = require("./noCacheRESTDataSource"); 3 | const { <% if(withMultiTenancy){ %>TenantId,<%}%> UserId, UserPassport } = require("../constants/customHttpHeaders"); 4 | const { assoc } = require('ramda') 5 | 6 | class ApiRESTDataSource extends NoCacheRESTDataSource { 7 | constructor(context) { 8 | super(context) 9 | this.context = context 10 | } 11 | 12 | willSendRequest(_path, request) { 13 | const { jwtdata } = this.context.state ?? {} 14 | <%_ if(withMultiTenancy){ _%> 15 | request.headers = assoc(TenantId, jwtdata?.tid, request.headers) 16 | <%_}_%> 17 | request.headers = assoc(UserPassport, jwtdata ? JSON.stringify(jwtdata) : undefined, request.headers) 18 | request.headers = assoc(UserId, jwtdata?.sub, request.headers) 19 | 20 | //TODO to be removed 21 | if (this.context.token) { 22 | request.headers = assoc('Authorization', `Bearer ${this.context.token}`, request.headers) 23 | } 24 | 25 | const acceptLanguage = this.context.request?.headers?.["accept-language"] 26 | if (acceptLanguage) 27 | request.headers['Accept-Language'] = acceptLanguage 28 | 29 | const correlationId = correlationManager.getCorrelationId(); 30 | if (correlationId) request.headers['x-correlation-id'] = correlationId 31 | } 32 | } 33 | 34 | module.exports = ApiRESTDataSource; 35 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/middleware/tenantIdentification/index.js: -------------------------------------------------------------------------------- 1 | 2 | const jsonwebtoken = require('jsonwebtoken') 3 | const { tenantService, tenantContextAccessor } = require('@totalsoft/multitenancy-core') 4 | 5 | const isMultiTenant = JSON.parse(process.env.IS_MULTITENANT || 'false') 6 | 7 | const tenantIdentification = () => async (ctx, next) => { 8 | const tenant = isMultiTenant ? await tenantService.getTenantFromId(getTenantIdFromJwt(ctx)) : {}; 9 | 10 | await tenantContextAccessor.useTenantContext({ tenant }, next); 11 | } 12 | 13 | const getTenantIdFromJwt = ({ token }) => { 14 | let tenantId = null 15 | if (token) { 16 | const decoded = jsonwebtoken.decode(token.replace('Bearer ', '')) 17 | if (decoded) { 18 | tenantId = decoded.tid 19 | } 20 | } 21 | return tenantId 22 | } 23 | 24 | // eslint-disable-next-line no-unused-vars 25 | const getTenantIdFromQueryString = ({ request }) => request.query.tenantId 26 | 27 | // eslint-disable-next-line no-unused-vars 28 | const getTenantIdFromHeaders = ctx => ctx.req.headers.tenantid 29 | 30 | // eslint-disable-next-line no-unused-vars 31 | const getTenantIdFromHost = ctx => ctx.hostname 32 | 33 | // eslint-disable-next-line no-unused-vars 34 | const getTenantIdFromRefererHost = async ctx => { 35 | if (!ctx.request.headers.referer) { 36 | return 37 | } 38 | var url = new URL.parse(ctx.request.headers.referer) 39 | return url.hostname 40 | } 41 | 42 | module.exports = tenantIdentification 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-rocket [![NPM version][npm-image]][npm-url] 2 | 3 | > GraphQL server sample with Apollo Server, Koa middleware, database reads using Prisma ORM, and/or REST API consumer, token validation, messaging integration with Redis and Nats and many other cool features. 4 | 5 | ![Building blocks](assets/img/appicon.png) 6 | 7 | > Check-out our [Webapp Rocket Generator](https://github.com/osstotalsoft/generator-webapp-rocket) a front-end web application generator with GraphQL, React and Apollo Client, and [osstotalsoft/rocket-toolkit](https://github.com/osstotalsoft/rocket-toolkit), a collection of plugins and other GraphQL utilities that may come in handy. 8 | 9 | ## See [DOCUMENTATION](https://totalsoft.gitbook.io/graphql-rocket-generator/). 10 | 11 | - [Quick-start guide](https://totalsoft.gitbook.io/graphql-rocket-generator/quick-start) 12 | - [Built-in architecture](https://totalsoft.gitbook.io/graphql-rocket-generator/built-in-architecture) 13 | 14 | ## Getting To Know Yeoman 15 | 16 | - Yeoman has a heart of gold. 17 | - Yeoman is a person with feelings and opinions, but is very easy to work with. 18 | - Yeoman can be too opinionated at times but is easily convinced not to be. 19 | - Feel free to [learn more about Yeoman](http://yeoman.io/). 20 | 21 | ## License 22 | 23 | **graphql-rocket** is licensed under the MIT license. @TotalSoft 24 | 25 | [npm-image]: https://badge.fury.io/js/%40totalsoft%2Fgenerator-graphql-rocket.svg 26 | [npm-url]: https://www.npmjs.com/package/@totalsoft/generator-graphql-rocket 27 | -------------------------------------------------------------------------------- /generators/__tests__/installers.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-unpublished-import */ 2 | 'use strict' 3 | import path, { dirname } from 'path' 4 | import assert from 'yeoman-assert' 5 | // eslint-disable-next-line node/no-missing-import 6 | import helpers from 'yeoman-test' 7 | import { fileURLToPath } from 'url' 8 | import { afterEach, describe, it } from 'mocha' 9 | import fs from 'fs-extra' 10 | 11 | const __filename = fileURLToPath(import.meta.url) 12 | const __dirname = dirname(__filename) 13 | 14 | describe('test package installers', function () { 15 | this.timeout(100 * 1000) 16 | 17 | const projectName = 'test-graphql' 18 | const dbConnectionName = 'testDatabase' 19 | const npm = `>= 9.x` 20 | 21 | afterEach(() => { 22 | const testDir = path.join(__dirname, projectName) 23 | if (fs.existsSync(testDir)) fs.removeSync(testDir) 24 | }) 25 | 26 | it('installs packages with npm', () => 27 | helpers 28 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 29 | .withAnswers({ 30 | projectName, 31 | withMultiTenancy: false, 32 | hasSharedDb: false, 33 | dbConnectionName, 34 | addSubscriptions: false, 35 | addMessaging: false, 36 | withRights: true, 37 | addHelm: false, 38 | addTracing: false 39 | }) 40 | .then(({ cwd }) => { 41 | assert.jsonFileContent(`${cwd}/${projectName}/package.json`, { 42 | name: projectName, 43 | engines: { npm } 44 | }) 45 | })) 46 | }) 47 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/.env: -------------------------------------------------------------------------------- 1 | PORT="4000" 2 | <%_ if(addMessaging) { _%> 3 | Messaging__Source="<%= projectName %>.GQL" 4 | Messaging__Transport="<%= messagingTransport %>" 5 | 6 | NATS_URL="nats://kube-worker1:31291" 7 | NATS_CLUSTER="faas-cluster" 8 | NATS_CLIENT_ID=<%= projectName %> 9 | NATS_Q_GROUP=<%= projectName %> 10 | NATS_DURABLE_NAME="durable" 11 | 12 | <%_}_%> 13 | <%_ if(addSubscriptions) { _%> 14 | REDIS_DOMAIN_NAME="" 15 | REDIS_PORT_NUMBER="6379" 16 | <%_}_%> 17 | 18 | 19 | # trace, debug, info, warn, error, fatal 20 | LOG_MIN_LEVEL=info 21 | LOG_DATABASE="" 22 | LOG_DATABASE_MINLEVEL=info 23 | LOG_DATABASE_ENABLED=false 24 | <%_ if(addTracing){ _%> 25 | LOG_OPENTELEMETRY_TRACING_MINLEVEL=info 26 | LOG_OPENTELEMETRY_TRACING_ENABLED=true 27 | <%_}_%> 28 | 29 | IDENTITY_API_URL= 30 | IDENTITY_AUTHORITY= 31 | IDENTITY_OPENID_CONFIGURATION= 32 | 33 | <%_ if(addTracing){ _%> 34 | OTEL_SERVICE_NAME=<%= projectName %> 35 | OTEL_TRACING_ENABLED=true 36 | OTEL_TRACE_PROXY=false 37 | <%_}_%> 38 | 39 | DATABASE_URL="sqlserver://serverName:1433;database=databaseName;user=userName;password=password;trustServerCertificate=true" 40 | <%_ if(withMultiTenancy){ _%> 41 | PRISMA_DB_URL_PATTERN="sqlserver://{server}:{port};database={database};user={user};password={password};trustServerCertificate=true" 42 | <%_}_%> 43 | PRISMA_DEBUG=false 44 | <%_ if(withMultiTenancy){ _%> 45 | IS_MULTITENANT=false 46 | <%_}_%> 47 | DIAGNOSTICS_ENABLED=false 48 | #DIAGNOSTICS_PORT= 49 | METRICS_ENABLED=false 50 | #OTEL_EXPORTER_PROMETHEUS_PORT= 51 | -------------------------------------------------------------------------------- /generators/utils.js: -------------------------------------------------------------------------------- 1 | import boxen from 'boxen' 2 | import updateNotifier from 'update-notifier' 3 | import chalk from 'chalk' 4 | // eslint-disable-next-line node/no-unsupported-features/es-syntax 5 | const pkg = await import('../package.json', { with: { type: 'json' } }) 6 | 7 | const notifier = updateNotifier({ pkg: pkg.default, updateCheckInterval: 0 }) 8 | 9 | async function checkForLatestVersion() { 10 | const updateInfo = await notifier.fetchInfo() 11 | if (updateInfo.current !== '0.0.0') console.log(updateInfo.current) 12 | if (updateInfo && updateInfo.current !== '0.0.0' && updateInfo.current < updateInfo.latest) { 13 | const current = chalk.redBright(updateInfo.current) 14 | const latest = chalk.greenBright(updateInfo.latest) 15 | console.log( 16 | boxen( 17 | `${chalk.redBright(`UPDATE AVAILABLE!`)} 18 | Please update your generator before you use it! 19 | ${current} -> ${latest} 20 | Enter ${chalk.cyanBright(`yo`)} and update the generator manually or run: 21 | ${chalk.cyanBright(`npm install -g @totalsoft/generator-graphql-rocket`)} 22 | `, 23 | { 24 | padding: 1, 25 | margin: 1, 26 | align: 'center', 27 | borderColor: 'yellow', 28 | borderStyle: 'round' 29 | } 30 | ) 31 | ) 32 | 33 | return false 34 | } 35 | 36 | return true 37 | } 38 | 39 | async function getCurrentVersion() { 40 | const updateInfo = await notifier.fetchInfo() 41 | return updateInfo.current 42 | } 43 | 44 | export { checkForLatestVersion, getCurrentVersion } 45 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/eslint.config.js: -------------------------------------------------------------------------------- 1 | const { fixupConfigRules } = require('@eslint/compat'), 2 | js = require('@eslint/js'), 3 | { FlatCompat } = require('@eslint/eslintrc') 4 | 5 | const compat = new FlatCompat({ 6 | baseDirectory: __dirname, 7 | recommendedConfig: js.configs.recommended, 8 | allConfig: js.configs.all 9 | }) 10 | 11 | module.exports = [ 12 | ...fixupConfigRules( 13 | compat.extends('eslint:recommended', 'plugin:node/recommended', 'plugin:jest/recommended', 'plugin:import/recommended') 14 | ), 15 | { 16 | languageOptions: { 17 | globals: { 18 | ...require('eslint-plugin-jest').environments.globals.globals 19 | }, 20 | ecmaVersion: 'latest', 21 | sourceType: 'commonjs' 22 | }, 23 | files: ['**/*.js'], 24 | rules: { 25 | 'node/exports-style': ['error', 'module.exports'], 26 | 'node/file-extension-in-import': ['error', 'always'], 27 | 'node/prefer-global/buffer': ['error', 'always'], 28 | 'node/prefer-global/console': ['error', 'always'], 29 | 'node/prefer-global/process': ['error', 'always'], 30 | 'node/prefer-global/url-search-params': ['error', 'always'], 31 | 'node/prefer-global/url': ['error', 'always'], 32 | 'node/prefer-promises/dns': 'error', 33 | 'node/prefer-promises/fs': 'error', 34 | 'node/no-unpublished-require': 0, 35 | 'no-unused-vars': [ 36 | 1, 37 | { 38 | args: 'after-used', 39 | argsIgnorePattern: '^_' 40 | } 41 | ] 42 | } 43 | }, 44 | { 45 | files: ['**/__tests__/**/*.js'], 46 | rules: { 47 | 'node/no-unpublished-require': 0 48 | } 49 | } 50 | ] 51 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/middleware/permissions/rules.js: -------------------------------------------------------------------------------- 1 | // Define your shield rules using graphql-shield (see docs https://github.com/maticzav/graphql-shield) 2 | <%_if(addQuickStart){ _%> 3 | const { rule } = require('graphql-shield') 4 | const { includes, intersection } = require('ramda') 5 | const { admin, globalAdmin } = require('../../constants/identityUserRoles') 6 | const { viewDashboard } = require('../../constants/permissions') 7 | const { GraphQLError } = require('graphql') 8 | const { prisma } = require('../../prisma') 9 | // strict - use when rule relies on parent or args parameter as well (field specific modifications) 10 | // Cannot use STRICT caching for upload types 11 | 12 | const isAuthenticated = rule({ cache: 'contextual' })( 13 | (_parent, _args, context) => !!context?.externalUser?.id 14 | ) 15 | 16 | const isAdmin = rule({ cache: 'contextual' })( 17 | (_parent, _args, { externalUser }, _info) => includes(admin, externalUser.role) || includes(globalAdmin, externalUser.role) 18 | ) 19 | 20 | const canViewDashboard = rule({ cache: 'contextual' })( 21 | (_parent, _args, context, _info) => checkForPermission([viewDashboard], context) 22 | ) 23 | 24 | const checkForPermission = async (permissions, { externalUser }) => { 25 | try { 26 | const rights = ( 27 | await prisma().user.findFirst({ where: { ExternalId: externalUser?.id } }).UserRights({ include: { Right: true } }) 28 | )?.map(x => x?.right?.name) 29 | 30 | return intersection(permissions, rights).length > 0 31 | } catch (error) { 32 | throw new GraphQLError(`Authorization check failed! The following error was encountered: ${error}`) 33 | } 34 | } 35 | 36 | 37 | module.exports = { 38 | isAuthenticated, isAdmin, canViewDashboard 39 | } 40 | <%_}_%> -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Program", 11 | "skipFiles": ["/**"], 12 | "program": "${workspaceFolder}\\src\\index.js", 13 | "outputCapture": "std", 14 | "env": { 15 | "NODE_ENV": "development" 16 | } 17 | }, 18 | { 19 | "type": "node", 20 | "request": "launch", 21 | "name": "Program with Kubernetes", 22 | "skipFiles": ["/**"], 23 | "program": "${workspaceFolder}\\src\\index.js", 24 | "preLaunchTask": "bridge-to-kubernetes.resource", 25 | "outputCapture": "std", 26 | "env": { 27 | "GRPC_DNS_RESOLVER": "native", 28 | "NODE_ENV": "development" 29 | } 30 | }, 31 | { 32 | "type": "node", 33 | "request": "launch", 34 | "name": "Jest Current File", 35 | "program": "${workspaceFolder}/node_modules/.bin/jest", 36 | "args": ["${fileBasenameNoExtension}"], 37 | "console": "integratedTerminal", 38 | "internalConsoleOptions": "neverOpen", 39 | "disableOptimisticBPs": true, 40 | "windows": { 41 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 42 | } 43 | }, 44 | { 45 | "type": "node", 46 | "request": "launch", 47 | "name": "Jest", 48 | "program": "${workspaceRoot}/node_modules/jest/bin/jest.js", 49 | "args": ["-i"], 50 | "skipFiles": ["/**/*.js", "node_modules"] 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/pubSub/middleware/tracingPublish.js: -------------------------------------------------------------------------------- 1 | const messagingEnvelopeHeaderSpanTagPrefix = "pubSub_header"; 2 | const { trace, context, propagation, SpanKind, SpanStatusCode } = require("@opentelemetry/api"); 3 | const { correlationManager } = require("@totalsoft/correlation"); 4 | <%_ if(withMultiTenancy){ _%> 5 | const { tenantContextAccessor } = require("@totalsoft/multitenancy-core"); 6 | <%_}_%> 7 | const attributeNames = require("../../constants/tracingAttributes"); 8 | const { SemanticAttributes } = require("@opentelemetry/semantic-conventions"); 9 | 10 | const componentName = "gql-pub-sub"; 11 | const tracer = trace.getTracer(componentName); 12 | 13 | const tracingPublish = async (ctx, next) => { 14 | const span = tracer.startSpan(`${ctx.clientTopic} send`, { 15 | attributes: { 16 | [SemanticAttributes.MESSAGE_BUS_DESTINATION]: ctx.topic, 17 | [attributeNames.correlationId]: correlationManager.getCorrelationId() 18 | <%_ if(withMultiTenancy){ _%>, 19 | [attributeNames.tenantId]: tenantContextAccessor.getTenantContext()?.tenant?.id 20 | <%_}_%> 21 | }, 22 | kind: SpanKind.PRODUCER 23 | }); 24 | 25 | const messageHeaders = ctx.message.headers; 26 | 27 | for (const header in ctx.messageHeaders) { 28 | span.setAttribute(`${messagingEnvelopeHeaderSpanTagPrefix}.${header.toLowerCase()}`, ctx.message.headers[header]); 29 | } 30 | 31 | try { 32 | const ctx = trace.setSpan(context.active(), span); 33 | propagation.inject(ctx, messageHeaders); 34 | 35 | return await context.with(ctx, next); 36 | } catch (error) { 37 | span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message }); 38 | span.recordException(error); 39 | throw error; 40 | } finally { 41 | span.end(); 42 | } 43 | }; 44 | 45 | module.exports = { tracingPublish }; 46 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/prisma/tenancyExtension.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | 3 | let prismaModels 4 | const TENANT_PROP = 'tenantId' 5 | 6 | function tenantFilterExtension(prismaClient, tenantId) { 7 | if (!prismaModels) prismaModels = prismaClient._runtimeDataModel 8 | return prismaClient.$extends({ 9 | query: { 10 | $allModels: { 11 | async $allOperations(params) { 12 | const { model, args, query } = params 13 | const { fields } = prismaModels?.models?.[model] || {} 14 | const tableHasColumnTenant = R.find(({ name }) => R.equals(name, TENANT_PROP), fields) 15 | if (tableHasColumnTenant) { 16 | const enrichedParams = addTenantFilter(params, tenantId) 17 | return query(enrichedParams?.args) 18 | } 19 | return query(args) 20 | } 21 | } 22 | } 23 | }) 24 | } 25 | 26 | const addTenantProperty = tenantId => obj => ({ ...obj, tenantId }) 27 | 28 | const addTenantFilter = (params, tenantId) => { 29 | const { operation, args } = params 30 | const fn = addTenantProperty(tenantId) 31 | 32 | return R.cond([ 33 | [R.equals('create'), () => R.assocPath(['args', 'data'], fn(args.data), params)], 34 | [ 35 | R.equals('createMany'), 36 | () => 37 | R.assocPath( 38 | ['args', 'data'], 39 | 40 | R.map(item => fn(item), args?.data ?? []), 41 | params 42 | ) 43 | ], 44 | [ 45 | R.equals('upsert'), 46 | () => 47 | R.assocPath( 48 | ['args'], 49 | { 50 | ...args, 51 | create: args?.create ? fn(args.create) : args?.create, 52 | where: args?.update ? fn(args.where) : args?.where 53 | }, 54 | params 55 | ) 56 | ], 57 | [R.T, () => R.assocPath(['args', 'where'], fn(args?.where), params)] 58 | ])(operation) 59 | } 60 | 61 | module.exports = { 62 | tenantFilterExtension 63 | } 64 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/prisma/utils.js: -------------------------------------------------------------------------------- 1 | const { defaultTo } = require('ramda') 2 | 3 | function cursorPaginationOptions(pager, direction = defaultTo(1, pager?.direction)) { 4 | const { afterId, pageSize, sortBy = 'id' } = pager 5 | const options = afterId 6 | ? { 7 | skip: 1, 8 | cursor: { 9 | id: afterId 10 | } 11 | } 12 | : {} 13 | 14 | return { 15 | ...options, 16 | take: pageSize, 17 | orderBy: { 18 | [sortBy]: direction ? 'asc' : 'desc' 19 | } 20 | } 21 | } 22 | 23 | /** 24 | * 25 | * @param prismaModel Prisma queried model 26 | * @param pager Pager input object with the following structure: 27 | * `{ 28 | * afterId: String 29 | * sortBy: String 30 | * direction: Int 31 | * pageSize: Int 32 | * }` 33 | * @param metadata Prisma Client `findMany` function arguments: 34 | * `{ 35 | * where: {}, 36 | * include: {}, 37 | * select: {}, 38 | * distinct: {} 39 | * }` 40 | * @returns `{ values: object[], pagination: Pagination }` 41 | */ 42 | async function prismaPaginated(prismaModel, pager = {}, metadata = {}) { 43 | const { pageSize, direction } = pager 44 | const options = { ...metadata, ...cursorPaginationOptions(pager) } 45 | const [values, totalCount, prevPageValues] = await Promise.all([ 46 | await prismaModel.findMany(options), 47 | await prismaModel.count({ where: metadata.where }), 48 | await prismaModel.findMany({ 49 | ...metadata, 50 | ...cursorPaginationOptions(pager, !direction), 51 | select: { id: true } 52 | }) 53 | ]) 54 | const prevAfterId = prevPageValues?.[pageSize - 1]?.id 55 | const nextAfterId = values[pageSize - 1]?.id 56 | const result = { 57 | values, 58 | pagination: { 59 | totalCount, 60 | prevPage: { ...pager, afterId: prevAfterId }, 61 | nextPage: { ...pager, afterId: nextAfterId } 62 | } 63 | } 64 | return result 65 | } 66 | 67 | module.exports = { cursorPaginationOptions, prismaPaginated } 68 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/middleware/auth/auth.js: -------------------------------------------------------------------------------- 1 | const jwt = require("koa-jwt"); 2 | const jwksRsa = require("jwks-rsa"); 3 | const jsonwebtoken = require('jsonwebtoken'); 4 | <%_ if(addSubscriptions){ _%> 5 | const { CloseCode } = require("graphql-ws"); 6 | <%_}_%> 7 | const { IDENTITY_AUTHORITY, IDENTITY_OPENID_CONFIGURATION } = process.env; 8 | 9 | const client = { 10 | cache: true, // Default Value 11 | cacheMaxEntries: 5, // Default value 12 | cacheMaxAge: 600000, // Defaults to 10m 13 | rateLimit: true, 14 | jwksRequestsPerMinute: 10, // Default value 15 | jwksUri: `${IDENTITY_AUTHORITY}${IDENTITY_OPENID_CONFIGURATION}` 16 | } 17 | const jwksRsaClient = jwksRsa(client); 18 | const validateJwtToken = jwt({ 19 | secret: jwksRsa.koaJwtSecret(client), 20 | issuer: IDENTITY_AUTHORITY, 21 | algorithms: ["RS256"], 22 | key: 'jwtdata', 23 | tokenKey: 'token', 24 | }); 25 | 26 | const jwtTokenValidation = (ctx, next) => { 27 | return validateJwtToken(ctx, next); 28 | } 29 | 30 | const jwtTokenUserIdentification = async (ctx, next) => { 31 | 32 | const { jwtdata, token } = ctx?.state ?? {} 33 | ctx.token = token 34 | ctx.externalUser = jwtdata ? { id: jwtdata.sub, role: jwtdata.role } : {} 35 | 36 | await next(); 37 | } 38 | 39 | const validateToken = async (token) => { 40 | const decoded = jsonwebtoken.decode(token, { complete: true }); 41 | 42 | const Promise = require("bluebird"); 43 | const getKey = Promise.promisify(jwksRsaClient.getSigningKey); 44 | const key = await getKey(decoded.header.kid); 45 | 46 | return jsonwebtoken.verify(token, key.getPublicKey()); 47 | } 48 | 49 | <% if(addSubscriptions){ %> 50 | const validateWsToken = async (token, socket) => { 51 | try { 52 | await validateToken(token); 53 | } catch { 54 | return socket?.close(CloseCode.Forbidden, "Forbidden! Jwt token is not valid!"); 55 | } 56 | } 57 | <%}%> 58 | 59 | module.exports = { jwtTokenValidation, jwtTokenUserIdentification, validateToken<% if(addSubscriptions){ %>, validateWsToken<%}%> } 60 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/messaging/messagingDataSource.js: -------------------------------------------------------------------------------- 1 | const { messageBus } = require('@totalsoft/message-bus') 2 | <%_ if(withMultiTenancy){ _%> 3 | const { tenantContextAccessor } = require('@totalsoft/multitenancy-core'); 4 | const isMultiTenant = JSON.parse(process.env.IS_MULTITENANT || 'false') 5 | <%_}_%> 6 | 7 | <%_ if(addTracing){ _%> 8 | const { tracingPublish } = require("./middleware"); 9 | <%_}_%> 10 | const { concat, run, pipelineBuilder } = require("../utils/pipeline"); 11 | const { correlationManager } = require("@totalsoft/correlation"); 12 | 13 | const publishPipeline = pipelineBuilder().use(<%if(addTracing){%>tracingPublish()<%}%>).build(); 14 | 15 | class MessagingDataSource { 16 | constructor(context) { 17 | this.context = { 18 | <%_ if(withMultiTenancy){ _%> 19 | tenantId: isMultiTenant ? tenantContextAccessor.getTenantContext().tenant?.id : undefined, 20 | <%_}_%> 21 | correlationId: correlationManager.getCorrelationId(), 22 | token: context.token, 23 | externalUser: context.externalUser 24 | } 25 | this.envelopeCustomizer = headers => ({ ...headers, UserId: this.context.externalUser.id }) 26 | this.msgBus = messageBus() 27 | } 28 | 29 | publish(topic, msg) { 30 | const pipeline = concat( 31 | (ctx, _next) => this.msgBus.publish(topic, msg, this.context, ctx.envelopeCustomizer), 32 | publishPipeline 33 | ); 34 | 35 | return run(pipeline, { envelopeCustomizer: this.envelopeCustomizer, topic }); 36 | } 37 | 38 | subscribe(topic, handler, opts) { 39 | return this.msgBus.subscribe(topic, handler, opts) 40 | } 41 | 42 | sendCommandAndReceiveEvent(topic, command, events) { 43 | const pipeline = concat( 44 | (ctx, _next) => 45 | this.msgBus.sendCommandAndReceiveEvent(topic, command, events, this.context, ctx.envelopeCustomizer), 46 | publishPipeline 47 | ); 48 | 49 | return run(pipeline, { envelopeCustomizer: this.envelopeCustomizer, topic }); 50 | } 51 | } 52 | 53 | module.exports = MessagingDataSource 54 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/helm/gql/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "Gql.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "Gql.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "Gql.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "Gql.labels" -}} 38 | helm.sh/chart: {{ include "Gql.chart" . }} 39 | {{ include "Gql.selectorLabels" . }} 40 | {{- if .Chart.AppVersion }} 41 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 42 | {{- end }} 43 | app.kubernetes.io/managed-by: {{ .Release.Service }} 44 | {{- end -}} 45 | 46 | {{/* 47 | Selector labels 48 | */}} 49 | {{- define "Gql.selectorLabels" -}} 50 | app.kubernetes.io/name: {{ include "Gql.name" . }} 51 | app.kubernetes.io/instance: {{ .Release.Name }} 52 | {{- end -}} 53 | 54 | {{/* 55 | Create the name of the service account to use 56 | */}} 57 | {{- define "Gql.serviceAccountName" -}} 58 | {{- if .Values.serviceAccount.create -}} 59 | {{ default (include "Gql.fullname" .) .Values.serviceAccount.name }} 60 | {{- else -}} 61 | {{ default "default" .Values.serviceAccount.name }} 62 | {{- end -}} 63 | {{- end -}} 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@totalsoft/generator-graphql-rocket", 3 | "version": "0.0.0", 4 | "description": "GraphQL server sample with Apollo Server, Koa and token validation.", 5 | "homepage": "https://github.com/osstotalsoft/generator-graphql-rocket", 6 | "author": { 7 | "name": "Totalsoft", 8 | "url": "https://github.com/osstotalsoft" 9 | }, 10 | "publishConfig": { 11 | "access": "public" 12 | }, 13 | "type": "module", 14 | "files": [ 15 | "generators" 16 | ], 17 | "main": "generators/index.js", 18 | "keywords": [ 19 | "GraphQL", 20 | "Apollo-Server", 21 | "Koa", 22 | "yeoman-generator" 23 | ], 24 | "engines": { 25 | "npm": ">= 9.x", 26 | "node": ">= 20.x" 27 | }, 28 | "scripts": { 29 | "test": "mocha ./**/__tests__/**/*.spec.js ", 30 | "test:coverage": "c8 npm run test", 31 | "eslint:report": "eslint .", 32 | "ejslint": "ejslint generators/app/templates/infrastructure" 33 | }, 34 | "dependencies": { 35 | "boxen": "^8.0.1", 36 | "chalk": "^5.3.0", 37 | "lodash": "^4.17.21", 38 | "path": "^0.12.7", 39 | "ramda": "^0.30.1", 40 | "update-notifier": "^7.3.1", 41 | "yeoman-generator": "^7.3.3", 42 | "yo": "^5.0.0", 43 | "yosay": "^3.0.0" 44 | }, 45 | "devDependencies": { 46 | "@eslint/compat": "^1.2.3", 47 | "@eslint/eslintrc": "^3.2.0", 48 | "@eslint/js": "^9.15.0", 49 | "c8": "^10.1.2", 50 | "chai": "^5.1.2", 51 | "ejs-lint": "^2.0.1", 52 | "eslint": "^9.15.0", 53 | "eslint-config-prettier": "^9.1.0", 54 | "eslint-plugin-mocha": "^10.5.0", 55 | "eslint-plugin-node": "^11.1.0", 56 | "eslint-plugin-prettier": "^5.2.1", 57 | "fs-extra": "^11.2.0", 58 | "globals": "^15.12.0", 59 | "mocha": "^10.8.2", 60 | "prettier": "^3.3.3", 61 | "yeoman-assert": "^3.1.1", 62 | "yeoman-test": "^10.0.1" 63 | }, 64 | "repository": { 65 | "type": "git", 66 | "url": "git+https://github.com/osstotalsoft/generator-webapp-rocket.git" 67 | }, 68 | "license": "MIT", 69 | "bugs": { 70 | "url": "https://github.com/osstotalsoft/generator-webapp-rocket/issues" 71 | }, 72 | "packageManager": "npm@9.0.0" 73 | } 74 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/subscriptions/middleware/tracing.js: -------------------------------------------------------------------------------- 1 | const { trace, context, propagation, SpanKind, SpanStatusCode } = require("@opentelemetry/api"); 2 | <%_ if(withMultiTenancy) {_%> 3 | const { tenantContextAccessor } = require("@totalsoft/multitenancy-core"); 4 | <%_}_%> 5 | const { correlationManager } = require("@totalsoft/correlation"); 6 | const { SemanticAttributes } = require("@opentelemetry/semantic-conventions"); 7 | const attributeNames = require("../../constants/tracingAttributes"); 8 | 9 | const pubsubEnvelopeHeaderSpanTagPrefix = "pubSub_header"; 10 | const componentName = "gql-pub-sub"; 11 | const tracer = trace.getTracer(componentName); 12 | 13 | const tracing = async (ctx, next) => { 14 | const otelContext = propagation.extract(context.active(), ctx.message.headers); 15 | 16 | const span = tracer.startSpan( 17 | `${ctx.message?.topic || "pubsub"} receive`, 18 | { 19 | attributes: { 20 | [attributeNames.correlationId]: correlationManager.getCorrelationId(), 21 | <%_ if(withMultiTenancy) {_%> 22 | [attributeNames.tenantId]: tenantContextAccessor.getTenantContext()?.tenant?.id, 23 | <%_}_%> 24 | [SemanticAttributes.MESSAGE_BUS_DESTINATION]: ctx.message?.topic 25 | }, 26 | kind: SpanKind.CONSUMER 27 | }, 28 | otelContext 29 | ); 30 | 31 | for (const header in ctx.message.headers) { 32 | span.setAttribute(`${pubsubEnvelopeHeaderSpanTagPrefix}.${header.toLowerCase()}`, ctx.message.headers[header]); 33 | } 34 | 35 | try { 36 | const ctx = trace.setSpan(context.active(), span); 37 | const gqlResult = await context.with(ctx, next); 38 | 39 | if (Array.isArray(gqlResult.errors)) { 40 | for (const error of gqlResult.errors) { 41 | span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message }); 42 | span.recordException(error); 43 | } 44 | } 45 | 46 | return gqlResult; 47 | } catch (error) { 48 | span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message }); 49 | span.recordException(error); 50 | throw error; 51 | } finally { 52 | span.end(); 53 | } 54 | }; 55 | 56 | module.exports = { tracing }; 57 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/messaging/middleware/multiTenancy/__tests__/tenantIdentification.tests.js: -------------------------------------------------------------------------------- 1 | const { envelope } = require('@totalsoft/message-bus') 2 | const { messagingHost } = require('@totalsoft/messaging-host') 3 | 4 | jest.mock('@totalsoft/multitenancy-core') 5 | 6 | const OLD_ENV = process.env 7 | describe('tenant identification tests:', () => { 8 | beforeEach(() => { 9 | jest.resetModules() // Most important - it clears the cache 10 | process.env = { ...process.env, IS_MULTITENANT: 'true' } 11 | }) 12 | 13 | afterAll(() => { 14 | process.env = OLD_ENV // Restore old environment 15 | }) 16 | 17 | it('should identify tenant from nbb tenantId header:', async () => { 18 | //arrange 19 | const tenantId = "some-tenant-id"; 20 | const tenantIdentification = require("../tenantIdentification"); 21 | const { tenantService, tenantContextAccessor } = require("@totalsoft/multitenancy-core"); 22 | tenantService.getTenantFromId.mockImplementation(tid => Promise.resolve({ id: tid })); 23 | const msg = envelope({}, { tenantId }); 24 | const ctx = messagingHost()._contextFactory("topic1", msg); 25 | 26 | //act 27 | await tenantIdentification()(ctx, () => Promise.resolve()); 28 | 29 | //assert 30 | expect(tenantContextAccessor.useTenantContext).toHaveBeenCalledWith( 31 | { tenant: expect.objectContaining({ id: tenantId }) }, 32 | expect.anything() 33 | ); 34 | }) 35 | 36 | it('should identify tenant from tid header:', async () => { 37 | //arrange 38 | const tenantId = "some-tenant-id"; 39 | const { tenantService, tenantContextAccessor } = require("@totalsoft/multitenancy-core"); 40 | const tenantIdentification = require("../tenantIdentification"); 41 | tenantService.getTenantFromId.mockImplementation(tid => Promise.resolve({ id: tid })); 42 | const msg = envelope({}, {}, _ => ({ tid: tenantId })); 43 | const ctx = messagingHost()._contextFactory("topic1", msg); 44 | 45 | //act 46 | await tenantIdentification()(ctx, () => Promise.resolve()) 47 | 48 | //assert 49 | expect(tenantContextAccessor.useTenantContext).toHaveBeenCalledWith( 50 | { tenant: expect.objectContaining({ id: tenantId }) }, 51 | expect.anything() 52 | ); 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import prettier from 'eslint-plugin-prettier' 2 | import mochaPlugin from 'eslint-plugin-mocha' 3 | import globals from 'globals' 4 | import { dirname } from 'path' 5 | import { fileURLToPath } from 'url' 6 | import js from '@eslint/js' 7 | import { FlatCompat } from '@eslint/eslintrc' 8 | import { fixupConfigRules } from '@eslint/compat' 9 | 10 | const __filename = fileURLToPath(import.meta.url) 11 | const __dirname = dirname(__filename) 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all 16 | }) 17 | 18 | export default [ 19 | { 20 | ignores: ['**/coverage', '**/templates'] 21 | }, 22 | mochaPlugin.configs.flat.recommended, 23 | ...fixupConfigRules(compat.extends('eslint:recommended', 'plugin:node/recommended', 'prettier')), 24 | { 25 | plugins: { 26 | prettier 27 | }, 28 | languageOptions: { 29 | globals: { 30 | ...globals.mocha, 31 | ...globals.node 32 | }, 33 | ecmaVersion: 'latest', 34 | sourceType: 'module' 35 | }, 36 | files: ['**/*.{js,ts,mjs}'], 37 | rules: { 38 | semi: 0, 39 | quotes: 0, 40 | indent: 0, 41 | 'linebreak-style': 0, 42 | 'no-console': 0, 43 | 'prettier/trailingComma': 'off', 44 | 'node/exports-style': ['error', 'module.exports'], 45 | 'node/file-extension-in-import': ['error', 'always'], 46 | 'node/prefer-global/buffer': ['error', 'always'], 47 | 'node/prefer-global/console': ['error', 'always'], 48 | 'node/prefer-global/process': ['error', 'always'], 49 | 'node/prefer-global/url-search-params': ['error', 'always'], 50 | 'node/prefer-global/url': ['error', 'always'], 51 | 'node/prefer-promises/dns': 'error', 52 | 'node/prefer-promises/fs': 'error', 53 | 'node/no-unpublished-require': 0, 54 | 'node/no-unsupported-features/es-syntax': [ 55 | 'error', 56 | { 57 | ignores: ['modules'] 58 | } 59 | ], 60 | 'no-unused-vars': [ 61 | 1, 62 | { 63 | args: 'after-used', 64 | argsIgnorePattern: '^_' 65 | } 66 | ], 67 | 'mocha/max-top-level-suites': ['warn', { limit: 2 }] 68 | } 69 | } 70 | ] 71 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/helm/gql/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for tasks-gql. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | global: 5 | imagePullSecrets: 6 | - name: "registrykey" 7 | 8 | runtimeConfiguration: 9 | enabled: false 10 | configMap: <%= helmChartName %>-runtime-config 11 | csi: 12 | secretProviderClass: <%= helmChartName %>-runtime-secrets 13 | 14 | gateway: 15 | enabled: false 16 | 17 | metrics: 18 | enabled: false 19 | port: 9464 20 | 21 | diagnostics: 22 | enabled: false 23 | port: 40001 24 | 25 | <%_ if(withMultiTenancy){ _%> 26 | multiTenancy: 27 | tenancyType: "MonoTenant" # "MultiTenant" "MonoTenant" 28 | <%_}_%> 29 | 30 | <%_ if(addTracing){ _%> 31 | otlp: 32 | enabled: true 33 | endpoint: http://localhost:4317 34 | <%_}_%> 35 | <%_ if(addMessaging) { _%> 36 | messaging: 37 | env: <%= projectName %> 38 | natsUrl: "nats://[SERVICE].[NAMESPACE]:[PORT]" 39 | natsCluster: "[CLUSTER_NAME]" 40 | natsDurableName: durable 41 | transport: "<%= messagingTransport %>" 42 | <%_}_%> 43 | 44 | gql: 45 | enabled: true 46 | replicaCount: 1 47 | name: "<%= helmChartName %>" 48 | image: 49 | repository: "[ORGANIZATION].azurecr.io/" 50 | pullPolicy: IfNotPresent 51 | name: <%= helmChartName %> 52 | tag: "" # overwrite from pipeline 53 | 54 | service: 55 | type: ClusterIP 56 | port: 80 57 | targetPort: 4000 58 | 59 | resources: 60 | limits: 61 | memory: 512Mi 62 | <%_ if(addMessaging) { _%> 63 | messaging: 64 | source: <%= projectName %> 65 | clientId: <%= projectName %> 66 | natsQGroup: <%= projectName %> 67 | <%_}_%> 68 | 69 | # Additional environment variables 70 | env: 71 | <%_ if(addSubscriptions) { _%> 72 | REDIS_DOMAIN_NAME: "[REDIS_DOMAIN_NAME]" 73 | REDIS_PORT_NUMBER: "[REDIS_PORT_NUMBER]" 74 | <%_}_%> 75 | IDENTITY_API_URL: "[IDENTITY_API_URI]" 76 | IDENTITY_AUTHORITY: "[IDENTITY_AUTHORITY_URL]" 77 | DATABASE_URL: "sqlserver://{server}:{port};database={database};user={user};password={password};trustServerCertificate=true" 78 | <%_ if(withMultiTenancy && !hasSharedDb){ _%> 79 | PRISMA_DB_URL_PATTERN: "sqlserver://{server}:{port};database={database};user={user};password={password};trustServerCertificate=true" 80 | <%_}_%> 81 | PORT: "4000" 82 | # 83 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/prisma/client.js: -------------------------------------------------------------------------------- 1 | const { PRISMA_DEBUG<% if(withMultiTenancy){ %>, IS_MULTITENANT<%if(!hasSharedDb){%>, PRISMA_DB_URL_PATTERN <%}%><%}%>} = process.env 2 | const { PrismaClient } = require('@prisma/client') 3 | <%_ if(withMultiTenancy){ _%> 4 | const { tenantContextAccessor <% if(!hasSharedDb) {%>, tenantConfiguration<%}%> } = require('@totalsoft/multitenancy-core') 5 | <%_ if(hasSharedDb){ _%> 6 | const { tenantFilterExtension } = require('./tenancyExtension') 7 | <%_}else{_%> 8 | const { sanitizeConnectionInfo } = require('../utils/functions') 9 | <%_}_%> 10 | const isMultiTenant = JSON.parse(IS_MULTITENANT) 11 | <%_}_%> 12 | const isDebug = JSON.parse(PRISMA_DEBUG ?? false) 13 | 14 | const cacheMap = new Map() 15 | const prismaOptions = { 16 | log: isDebug ? ['query', 'info', 'warn', 'error'] : ['warn', 'error'] 17 | } 18 | 19 | function prisma() { 20 | let prismaClient 21 | <%_ if(withMultiTenancy){ _%> 22 | if (isMultiTenant) { 23 | const tenantContext = tenantContextAccessor.getTenantContext() 24 | const tenantId = tenantContext?.tenant?.id 25 | if (!tenantId) throw new Error(`Could not identify tenant!`) 26 | 27 | if (cacheMap.has(tenantId)) return cacheMap.get(tenantId) 28 | 29 | <%_ if(hasSharedDb){ _%> 30 | prismaClient = new PrismaClient(prismaOptions) 31 | prismaClient = tenantFilterExtension(prismaClient, tenantId) 32 | cacheMap.set(tenantId, prismaClient) 33 | <%_} else { _%> 34 | const connectionInfo = tenantConfiguration.getConnectionInfo(tenantId, '<%= dbConnectionName %>') 35 | const { server, port, database, userName, password } = sanitizeConnectionInfo(connectionInfo) 36 | const url = PRISMA_DB_URL_PATTERN.replace('{server}', server) 37 | .replace('{port}', port) 38 | .replace('{database}', database) 39 | .replace('{user}', userName) 40 | .replace('{password}', password) 41 | 42 | prismaClient = new PrismaClient({ ...prismaOptions, datasources: { db: { url } } }) 43 | cacheMap.set(tenantId, prismaClient) 44 | <%_}_%> 45 | } else { 46 | if (cacheMap.has('default')) return cacheMap.get('default') 47 | prismaClient = new PrismaClient(prismaOptions) 48 | cacheMap.set('default', prismaClient) 49 | } 50 | <%_} else { _%> 51 | if (cacheMap.has('default')) return cacheMap.get('default') 52 | prismaClient = new PrismaClient(prismaOptions) 53 | cacheMap.set('default', prismaClient) 54 | <%_}_%> 55 | 56 | return prismaClient 57 | } 58 | 59 | module.exports = { prisma } 60 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/features/user/resolvers.js: -------------------------------------------------------------------------------- 1 | <%_ if(addSubscriptions){ _%> 2 | const { topics, pubSub } = require('../../pubSub') 3 | <%_}_%> 4 | <%_ if(addSubscriptions && withMultiTenancy){ _%> 5 | const { envelope } = require("@totalsoft/message-bus") 6 | const { withFilter } = require('graphql-subscriptions') 7 | <%_}_%> 8 | const { prisma, prismaPaginated } = require('../../prisma') 9 | 10 | const userResolvers = { 11 | Query: { 12 | userData: (_, { id }, _ctx, _info) => prisma().user.findUnique({ where: { id } }), 13 | userList: (_parent, { pager, filters }, _ctx) => 14 | prismaPaginated(prisma().user, pager, { where: { name: { contains: filters?.name } } }) 15 | }, 16 | User: { 17 | rights: async ({ id }, _params, _ctx, _info) => { 18 | const userRights = await prisma().userRight.findUnique({ 19 | where: { userId: id }, 20 | include: { right: true } 21 | }) 22 | return userRights.map(r => r?.right?.name) 23 | } 24 | }, 25 | //Not working! Only for demonstration 26 | Mutation: { 27 | updateUser: async (_, { input }, _ctx, _info) => { 28 | const { id } = input 29 | return prisma().user.update({ data: input, where: { id } }) 30 | } 31 | }, 32 | <%_ if(addSubscriptions){ _%> 33 | //Not working! Only for demonstration 34 | Subscription: { 35 | userChanged: { 36 | resolve: async (msg, _variables, _context, _info) => { 37 | return msg.payload 38 | }, 39 | <%_ if(withMultiTenancy){ _%> 40 | subscribe: withFilter( 41 | (_parent, _args, _context) => pubSub.asyncIterator(topics.USER_CHANGED), 42 | (message, _params, { tenant, logger }, _info) => { 43 | logger.logInfo(`📨 Message received from ${topics.USER_CHANGED}: ${JSON.stringify(message)}`, '[Message_Received]', true); 44 | logger.logInfo(`📨 Message tenant id = ${envelope.getTenantId(message).toUpperCase()}; Context tenant id = ${tenant?.id?.toUpperCase()}`, 45 | '[Message_Tenant_Check]', true); 46 | return envelope.getTenantId(message).toUpperCase() === tenant?.id?.toUpperCase() 47 | } 48 | ) 49 | <%_} else { _%> 50 | subscribe: (_parent, _args, _context) => pubSub.asyncIterator(topics.USER_CHANGED) 51 | <%_}_%> 52 | } 53 | } 54 | <%_}_%> 55 | } 56 | 57 | module.exports = userResolvers 58 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/utils/functions.js: -------------------------------------------------------------------------------- 1 | const humps = require("humps"); 2 | 3 | const randomCharacters = length => Math.random().toString(36).substr(2, length) 4 | 5 | const formatArrayUrlParams = obj => { 6 | const searchParams = new URLSearchParams(); 7 | Object.keys(obj).forEach(a => 8 | Array.isArray(obj[a]) ? obj[a].forEach(arr => searchParams.append(a, arr)) : searchParams.append(a, obj[a]) 9 | ); 10 | return searchParams; 11 | }; 12 | 13 | function JSONConverter(obj) { 14 | const keys = Object.keys(obj); 15 | let newObj = {}; 16 | while (keys.length--) { 17 | const key = keys[keys.length]; 18 | newObj[key.charAt(0).toLowerCase() + key.slice(1)] = obj[key]; 19 | } 20 | return newObj; 21 | } 22 | 23 | const postProcessDbResponse = result => { 24 | if (Array.isArray(result)) { 25 | return result.map(row => humps.camelizeKeys(row)); 26 | } else { 27 | return humps.camelizeKeys(result); 28 | } 29 | }; 30 | 31 | const parseConnectionString = connectionString => { 32 | const parsed = connectionString 33 | .replace(" ", "") 34 | .split(";") 35 | .reduce((a, b) => { 36 | const prop = b.split("="); 37 | return (a[prop[0]] = prop[1]), a; 38 | }, {}); 39 | 40 | return sanitizeConnectionInfo(parsed); 41 | }; 42 | 43 | 44 | const sanitizeConnectionInfo = connectionInfo => { 45 | connectionInfo = humps.camelizeKeys(connectionInfo) 46 | 47 | const portSplit = connectionInfo.server?.split(',') 48 | if (portSplit?.length > 1) { 49 | connectionInfo.server = portSplit[0] 50 | connectionInfo.port = portSplit[1] 51 | } 52 | 53 | const instanceSplit = connectionInfo.server?.split('\\') 54 | if (instanceSplit?.length > 1) { 55 | connectionInfo.server = instanceSplit[0] 56 | connectionInfo.instanceName = instanceSplit[1] 57 | } 58 | 59 | const otherParams = connectionInfo.otherParams 60 | ?.split(';') 61 | .filter(i => i) 62 | .map(pair => pair.split('=')) 63 | if (otherParams) { 64 | connectionInfo = { ...connectionInfo, ...humps.camelizeKeys(Object.fromEntries(otherParams)) } 65 | } 66 | 67 | return connectionInfo 68 | } 69 | 70 | const publicRoute = (ctx, publicRoutes = []) => { 71 | if ( 72 | ctx.method === 'GET' || 73 | ctx.request.body.operationName === 'IntrospectionQuery' || 74 | (ctx.request.body.query && ctx.request.body.query.includes("IntrospectionQuery")) || 75 | publicRoutes.includes(ctx.path.toLowerCase()) 76 | ) { 77 | return true 78 | } else { 79 | return false 80 | } 81 | } 82 | 83 | module.exports = { randomCharacters, formatArrayUrlParams, JSONConverter, postProcessDbResponse, parseConnectionString, sanitizeConnectionInfo, publicRoute }; 84 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/startup/logger.js: -------------------------------------------------------------------------------- 1 | <%_ if(withMultiTenancy) {_%> 2 | const { tenantIdMixin, tenantCodeMixin } = require("@totalsoft/pino-multitenancy"); 3 | const isMultiTenant = JSON.parse(process.env.IS_MULTITENANT || "false"); 4 | <%_}_%> 5 | const { correlationMixin } = require("@totalsoft/pino-correlation"); 6 | <%_ if(addTracing) {_%> 7 | const { openTelemetryTracingTransport } = require("@totalsoft/pino-opentelemetry"); 8 | <%_}_%> 9 | 10 | // General settings 11 | const { LOG_MIN_LEVEL } = process.env, 12 | logMinLevel = LOG_MIN_LEVEL || "info"; 13 | 14 | // DB transport settings 15 | const { LOG_DATABASE, LOG_DATABASE_MINLEVEL, LOG_DATABASE_ENABLED } = process.env, 16 | logDatabaseEnabled = JSON.parse(LOG_DATABASE_ENABLED || "false"), 17 | logDatabaseMinLevel = LOG_DATABASE_MINLEVEL || "info"; 18 | 19 | <%_ if(addTracing) {_%> 20 | // OpenTracing transport settings 21 | const { LOG_OPENTELEMETRY_TRACING_ENABLED, LOG_OPENTELEMETRY_TRACING_MINLEVEL, OTEL_TRACING_ENABLED } = process.env, 22 | tracingEnabled = JSON.parse(OTEL_TRACING_ENABLED || 'false'), 23 | logOpenTelemetryTracingEnabled = tracingEnabled && JSON.parse(LOG_OPENTELEMETRY_TRACING_ENABLED || "false"), 24 | logOpenTelemetryTracingMinLevel = LOG_OPENTELEMETRY_TRACING_MINLEVEL || "info"; 25 | <%_}_%> 26 | 27 | const pino = require("pino"); 28 | 29 | const options = { 30 | level: logMinLevel, 31 | timestamp: pino.stdTimeFunctions.isoTime, 32 | mixin(_context, _level) { 33 | return { ...correlationMixin()<% if(withMultiTenancy) {%>, ...tenantIdMixin(), ...tenantCodeMixin()<%}%> }; 34 | } 35 | }; 36 | const transport = pino.transport({ 37 | targets: [ 38 | ...(logDatabaseEnabled 39 | ? [ 40 | { 41 | target: "@totalsoft/pino-mssqlserver", 42 | options: { 43 | serviceName: "<%= projectName %>", 44 | tableName: "__Logs", 45 | connectionString: LOG_DATABASE 46 | }, 47 | level: logDatabaseMinLevel 48 | } 49 | ] 50 | : []), 51 | { 52 | target: "pino-pretty", 53 | options: { 54 | ignore: "pid,hostname,correlationId,tenantId,tenantCode,requestId,operationName,trace_id,span_id,trace_flags", 55 | translateTime: 'SYS:yyyy/mm/dd HH:MM:ss.l', 56 | <%_ if(withMultiTenancy) {_%> 57 | messageFormat: isMultiTenant ? "[{tenantCode}] {msg}" : false 58 | <%_}_%> 59 | }, 60 | level: "trace" 61 | } 62 | ] 63 | }); 64 | 65 | <%_ if(addTracing) { _%> 66 | var streams = pino.multistream([ 67 | { stream: transport, level: "trace" }, 68 | ...(logOpenTelemetryTracingEnabled ? [{ stream: openTelemetryTracingTransport(), level: logOpenTelemetryTracingMinLevel }] : []) 69 | ]); 70 | 71 | const logger = pino(options, streams); 72 | <%_} else {_%> 73 | const logger = pino(options, transport); 74 | <%_}_%> 75 | 76 | module.exports = logger; 77 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/index.js: -------------------------------------------------------------------------------- 1 | //env 2 | process.chdir(`${__dirname}/..`) 3 | const dotenv = require('dotenv') 4 | const result = dotenv.config() 5 | if (result.error) { 6 | console.warn('No .env file found in current working directory:', result.error) 7 | const path = `.env` 8 | const pathResult = dotenv.config({ path }) 9 | if (pathResult.error) { 10 | console.warn('No .env file found in project root directory:', pathResult.error) 11 | } 12 | } 13 | 14 | if (process.env.NODE_ENV) { 15 | const nodeEnvResult = dotenv.config({ path: `./.env.${process.env.NODE_ENV}`, override: true }) 16 | if (nodeEnvResult.error) { 17 | console.warn(`No .env.${process.env.NODE_ENV} file found in project root directory:`, nodeEnvResult.error) 18 | } 19 | } 20 | 21 | const keyPerFileEnv = require('@totalsoft/key-per-file-configuration') 22 | const configMonitor = keyPerFileEnv.load() 23 | 24 | require('console-stamp')(global.console, { 25 | format: ':date(yyyy/mm/dd HH:MM:ss.l)' 26 | }) 27 | 28 | const { logger<% if(addTracing) {%>, tracer<%}%> } = require("./startup"), 29 | { createServer } = require("http"), 30 | { startApolloServer<% if(addMessaging) { %>, startMsgHost <% } %> <% if(addSubscriptions){ %>, startSubscriptionServer <% } %>} = require('./servers') 31 | 32 | // Metrics, diagnostics 33 | const 34 | { DIAGNOSTICS_ENABLED, METRICS_ENABLED<% if(addTracing) {%>, OTEL_TRACING_ENABLED<%}%> } = process.env, 35 | <%_ if(addTracing) {_%> 36 | tracingEnabled = JSON.parse(OTEL_TRACING_ENABLED), 37 | <%_}_%> 38 | diagnosticsEnabled = JSON.parse(DIAGNOSTICS_ENABLED), 39 | metricsEnabled = JSON.parse(METRICS_ENABLED), 40 | {startMetrics, startDiagnostics} = require('@totalsoft/metrics') 41 | 42 | <%_ if(addTracing) {_%> 43 | if (tracingEnabled) tracer.start({ logger }) 44 | <%_}_%> 45 | 46 | const httpServer = createServer(); 47 | <%_ if(addSubscriptions){ _%> 48 | const subscriptionServer = startSubscriptionServer(httpServer); 49 | <%_}_%> 50 | const apolloServerPromise = startApolloServer(httpServer<% if(addSubscriptions) {%>, subscriptionServer<%}%>); 51 | <%_ if(addMessaging) {_%> 52 | const msgHost = startMsgHost(); 53 | <%_}_%> 54 | 55 | const port = process.env.PORT || 4000; 56 | httpServer.listen(port, () => { 57 | logger.info(`🚀 Server ready at http://localhost:${port}/graphql`) 58 | <%_ if(addSubscriptions){ _%> 59 | logger.info(`🚀 Subscriptions ready at ws://localhost:${port}/graphql`) 60 | <%_}_%> 61 | }) 62 | 63 | async function cleanup() { 64 | await configMonitor?.close(); 65 | <%_ if(addMessaging) {_%> 66 | await msgHost?.stop(); 67 | <%_}_%> 68 | await (await apolloServerPromise)?.stop(); 69 | <%_ if(addTracing) {_%> 70 | await tracer?.shutdown({ logger }); 71 | <%_}_%> 72 | } 73 | 74 | const { gracefulShutdown } = require('@totalsoft/graceful-shutdown'); 75 | gracefulShutdown({ 76 | onShutdown: cleanup, 77 | terminationSignals: ['SIGINT', 'SIGTERM', 'SIGUSR1', 'SIGUSR2'], 78 | unrecoverableEvents: ['uncaughtException', 'unhandledRejection'], 79 | logger, 80 | timeout: 5000 81 | }) 82 | 83 | diagnosticsEnabled && startDiagnostics(logger); 84 | metricsEnabled && startMetrics(logger); 85 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/messaging/middleware/tracing/index.js: -------------------------------------------------------------------------------- 1 | const { trace, context, propagation, SpanKind, SpanStatusCode } = require("@opentelemetry/api"); 2 | <%_ if(withMultiTenancy){ _%> 3 | const { tenantContextAccessor } = require("@totalsoft/multitenancy-core"); 4 | <%_}_%> 5 | const { correlationManager } = require("@totalsoft/correlation"); 6 | const { SemanticAttributes } = require("@opentelemetry/semantic-conventions"); 7 | const attributeNames = require("../../../constants/tracingAttributes"); 8 | 9 | const messagingEnvelopeHeaderSpanTagPrefix = "messaging_header"; 10 | const componentName = "nodebb-messaging"; 11 | const tracer = trace.getTracer(componentName); 12 | 13 | 14 | const tracing = () => async (ctx, next) => { 15 | const otelContext = propagation.extract(context.active(), ctx.received.msg.headers); 16 | const span = tracer.startSpan( 17 | `${ctx.received.topic} receive`, 18 | { 19 | attributes: { 20 | [attributeNames.correlationId]: correlationManager.getCorrelationId(), 21 | <%_ if(withMultiTenancy){ _%> 22 | [attributeNames.tenantId]: tenantContextAccessor.getTenantContext()?.tenant?.id, 23 | <%_}_%> 24 | [SemanticAttributes.MESSAGE_BUS_DESTINATION]: ctx.received.topic 25 | }, 26 | kind: SpanKind.CONSUMER 27 | }, 28 | otelContext 29 | ); 30 | 31 | ctx.requestSpan = span; 32 | span.addEvent('message', { 'message-payload': JSON.stringify(ctx.received.msg.payload).substring(0, 500) }) 33 | 34 | for (const header in ctx.received.msg.headers) { 35 | span.setAttribute( 36 | `${messagingEnvelopeHeaderSpanTagPrefix}.${header.toLowerCase()}`, 37 | ctx.received.msg.headers[header] 38 | ); 39 | } 40 | 41 | try { 42 | const ctx = trace.setSpan(context.active(), span); 43 | await context.with(ctx, next); 44 | } catch (error) { 45 | span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message }); 46 | span.recordException(error); 47 | 48 | throw error; 49 | } finally { 50 | span.end(); 51 | } 52 | } 53 | 54 | const tracingPublish = () => async (ctx, next) => { 55 | const span = tracer.startSpan(`${ctx.topic} send`, { 56 | attributes: { 57 | [SemanticAttributes.MESSAGE_BUS_DESTINATION]: ctx.topic, 58 | [attributeNames.correlationId]: correlationManager.getCorrelationId(), 59 | <%_ if(withMultiTenancy){ _%> 60 | [attributeNames.tenantId]: tenantContextAccessor.getTenantContext()?.tenant?.id 61 | <%_}_%> 62 | }, 63 | kind: SpanKind.PRODUCER 64 | }); 65 | 66 | const existingCustomizer = ctx.envelopeCustomizer; 67 | ctx.envelopeCustomizer = headers => { 68 | propagation.inject(context.active(), headers); 69 | 70 | for (const header in headers) { 71 | span.setAttribute(`${messagingEnvelopeHeaderSpanTagPrefix}.${header.toLowerCase()}`, headers[header]); 72 | } 73 | 74 | return existingCustomizer(headers); 75 | }; 76 | try { 77 | const ctx = trace.setSpan(context.active(), span); 78 | return await context.with(ctx, next); 79 | } catch (error) { 80 | span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message }); 81 | span.recordException(error); 82 | throw error; 83 | } finally { 84 | span.end(); 85 | } 86 | }; 87 | 88 | module.exports = { tracing, tracingPublish }; 89 | 90 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/servers/subscription.js: -------------------------------------------------------------------------------- 1 | <%_ if(withMultiTenancy){ _%> 2 | const { tenantService } = require("@totalsoft/multitenancy-core"), 3 | isMultiTenant = JSON.parse(process.env.IS_MULTITENANT || "false"); 4 | <%_}_%> 5 | const { WebSocketServer } = require("ws"); 6 | const WebSocket = require("ws"); // workaround for opentelemetry-instrumentation-ws 7 | const { correlation<% if(withMultiTenancy) {%>, tenantContext<%}%><% if(addTracing) {%>, tracing<%}%> } = require("../subscriptions/middleware"), 8 | { subscribe } = require("../subscriptions"); 9 | 10 | const { GraphQLError } = require("graphql"), 11 | { useServer } = require("graphql-ws/lib/use/ws"), 12 | { validateWsToken } = require("../middleware"), 13 | { schema, logger, getDataSources } = require("../startup"), 14 | jsonwebtoken = require("jsonwebtoken"), 15 | {recordSubscriptionStarted} = require("@totalsoft/metrics"), 16 | metricsEnabled = JSON.parse(process.env.METRICS_ENABLED); 17 | 18 | logger.info('Creating Subscription Server...') 19 | const startSubscriptionServer = httpServer => 20 | useServer( 21 | { 22 | schema, 23 | onConnect: async ctx => { 24 | const connectionParams = ctx?.connectionParams; 25 | const token = connectionParams.authorization.replace("Bearer ", ""); 26 | if (!token) { 27 | throw new GraphQLError("401 Unauthorized", { extensions: { code: "UNAUTHORIZED" } }); 28 | } 29 | ctx.token = token; 30 | 31 | await validateWsToken(token, ctx?.extra?.socket); 32 | 33 | const decoded = jsonwebtoken.decode(token); 34 | ctx.externalUser = { 35 | id: decoded?.sub, 36 | role: decoded?.role 37 | }; 38 | 39 | <%_ if(withMultiTenancy){ _%> 40 | if (isMultiTenant) { 41 | const tenantId = decoded?.tid; 42 | ctx.tenant = await tenantService.getTenantFromId(tenantId); 43 | } else { 44 | ctx.tenant = {}; 45 | } 46 | <%_}_%> 47 | 48 | ctx.state = {jwtdata:decoded, token} 49 | }, 50 | subscribe: subscribe({ 51 | middleware: [correlation<% if(withMultiTenancy) {%>, tenantContext<%}%><% if(addTracing) {%>, tracing<%}%>], 52 | <% if(withMultiTenancy) {%>filter: ctx => message => ctx?.tenant?.id?.toLowerCase() === message?.headers?.pubSubTenantId?.toLowerCase()<%}%> 53 | }), 54 | onSubscribe: async (ctx, msg) => { 55 | await validateWsToken(ctx?.token, ctx?.extra?.socket); 56 | metricsEnabled && recordSubscriptionStarted(msg); 57 | }, 58 | onDisconnect: (_ctx, code, reason) => 59 | code != 1000 && logger.info(`Subscription server disconnected! Code: ${code} Reason: ${reason}`), 60 | onError: (ctx, msg, errors) => logger.error("Subscription error!", { ctx, msg, errors }), 61 | context: async (ctx, msg, _args) => { 62 | <%_ if(withMultiTenancy){ _%> 63 | const { tenant } = ctx; 64 | <%_}_%> 65 | const dataSources = getDataSources(ctx) 66 | const subscriptionLogger = logger.child({ operationName: msg?.payload?.operationName }); 67 | 68 | return { 69 | ...ctx, 70 | <%_ if(withMultiTenancy){ _%> 71 | tenant, 72 | <%_}_%> 73 | dataSources, 74 | logger: subscriptionLogger 75 | } 76 | } 77 | }, 78 | new WebSocketServer({ 79 | server: httpServer, 80 | path: "/graphql", 81 | WebSocket 82 | }) 83 | ); 84 | 85 | module.exports = startSubscriptionServer; 86 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/subscriptions/extensibleSubscription.js: -------------------------------------------------------------------------------- 1 | const { isAsyncIterable } = require("graphql/jsutils/isAsyncIterable"); 2 | const { mapAsyncIterator } = require("graphql/execution/mapAsyncIterator"); 3 | const { execute, createSourceEventStream } = require("graphql"); 4 | const { pipelineBuilder } = require("../utils/pipeline"); 5 | 6 | function subscribe({ middleware, filter }) { 7 | const pipeline = pipelineBuilder() 8 | .use(...middleware) 9 | .build(); 10 | 11 | return function ( 12 | argsOrSchema, 13 | document, 14 | rootValue, 15 | contextValue, 16 | variableValues, 17 | operationName, 18 | fieldResolver, 19 | subscribeFieldResolver 20 | ) { 21 | return arguments.length === 1 22 | ? subscribeImpl(argsOrSchema, pipeline, filter) 23 | : subscribeImpl( 24 | { 25 | schema: argsOrSchema, 26 | document, 27 | rootValue, 28 | contextValue, 29 | variableValues, 30 | operationName, 31 | fieldResolver, 32 | subscribeFieldResolver 33 | }, 34 | pipeline, 35 | filter 36 | ); 37 | }; 38 | } 39 | 40 | function filterAsyncIterable(asyncIterable, predicate) { 41 | return { 42 | async next() { 43 | // eslint-disable-next-line no-constant-condition 44 | while (true) { 45 | const item = await asyncIterable.next(); 46 | if (item.done) { 47 | return { done: true }; 48 | } 49 | if (predicate(item.value)) { 50 | return item; 51 | } 52 | } 53 | }, 54 | return(val) { return asyncIterable.return(val) }, 55 | throw() { return asyncIterable.throw() }, 56 | [Symbol.asyncIterator]() { return this } 57 | } 58 | } 59 | 60 | function subscribeImpl(args, pipeline, filter) { 61 | const { 62 | schema, 63 | document, 64 | rootValue, 65 | contextValue, 66 | variableValues, 67 | operationName, 68 | fieldResolver, 69 | subscribeFieldResolver 70 | } = args 71 | 72 | const sourcePromise = createSourceEventStream( 73 | schema, 74 | document, 75 | rootValue, 76 | contextValue, 77 | variableValues, 78 | operationName, 79 | subscribeFieldResolver 80 | ); 81 | 82 | // For each payload yielded from a subscription, map it over the normal 83 | // GraphQL `execute` function, with `payload` as the rootValue. 84 | // This implements the "MapSourceToResponseEvent" algorithm described in 85 | // the GraphQL specification. The `execute` function provides the 86 | // "ExecuteSubscriptionEvent" algorithm, as it is nearly identical to the 87 | // "ExecuteQuery" algorithm, for which `execute` is also used. 88 | const mapSourceToResponse = async function mapSourceToResponse(payload) { 89 | return pipeline({ message: payload, context: contextValue }, () => 90 | execute({ 91 | schema, 92 | document, 93 | rootValue: payload, 94 | contextValue, 95 | variableValues, 96 | operationName, 97 | fieldResolver 98 | }) 99 | ); 100 | }; 101 | 102 | return sourcePromise.then(function (resultOrStream) { 103 | return isAsyncIterable(resultOrStream) 104 | ? mapAsyncIterator( 105 | filter ? filterAsyncIterable(resultOrStream, filter(contextValue)) : resultOrStream, 106 | mapSourceToResponse 107 | ) 108 | : resultOrStream; 109 | }); 110 | } 111 | 112 | module.exports = { subscribe }; 113 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/servers/apollo.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer } = require("@apollo/server"), 2 | Koa = require("koa"), 3 | { ApolloServerPluginDrainHttpServer } = require("@apollo/server/plugin/drainHttpServer"), 4 | { ApolloLoggerPlugin } = require("@totalsoft/pino-apollo"), 5 | bodyParser = require("koa-bodyparser"), 6 | { 7 | errorHandlingMiddleware, 8 | correlationMiddleware, 9 | <%_ if(addTracing) {_%> 10 | tracingMiddleware, 11 | <%_}_%> 12 | loggingMiddleware, 13 | jwtTokenValidation, 14 | jwtTokenUserIdentification<% if(withMultiTenancy){ %>,tenantIdentification <%}%> 15 | } = require("../middleware"), 16 | cors = require("@koa/cors"), 17 | { publicRoute } = require("../utils/functions"), 18 | ignore = require("koa-ignore"), 19 | { koaMiddleware } = require("@as-integrations/koa"), 20 | { schema, getDataSources, logger } = require("../startup"), 21 | { <% if(addTracing){ %> OTEL_TRACING_ENABLED,<% } %> METRICS_ENABLED } = process.env, 22 | <%_ if(addTracing){ _%> 23 | tracingEnabled = JSON.parse(OTEL_TRACING_ENABLED), 24 | <%_}_%> 25 | {createMetricsPlugin} = require("@totalsoft/metrics"), 26 | metricsEnabled = JSON.parse(METRICS_ENABLED); 27 | 28 | const plugins = (httpServer<% if(addSubscriptions) {%>, subscriptionServer<%}%>) => { 29 | return [ 30 | ApolloServerPluginDrainHttpServer({ httpServer }), 31 | new ApolloLoggerPlugin({ logger, securedMessages: false }), 32 | <%_ if(addSubscriptions) {_%> 33 | { 34 | async serverWillStart() { 35 | return { 36 | async drainServer() { 37 | await subscriptionServer.dispose(); 38 | } 39 | }; 40 | } 41 | }, 42 | <%_}_%> 43 | metricsEnabled ? createMetricsPlugin() : {} 44 | ]; 45 | }; 46 | 47 | const startApolloServer = async (httpServer<% if(addSubscriptions) {%>, subscriptionServer<%}%>) => { 48 | logger.info("Creating Apollo Server..."); 49 | const apolloServer = new ApolloServer({ 50 | schema, 51 | stopOnTerminationSignals: false, 52 | uploads: false, 53 | plugins: plugins(httpServer<% if(addSubscriptions) {%>, subscriptionServer<%}%>) 54 | }) 55 | 56 | await apolloServer.start() 57 | 58 | const app = new Koa(); 59 | app.use(loggingMiddleware) 60 | .use(errorHandlingMiddleware()) 61 | .use(bodyParser()) 62 | .use(correlationMiddleware()) 63 | .use(cors({ credentials: true })) 64 | .use(ignore(jwtTokenValidation, jwtTokenUserIdentification<% if(withMultiTenancy) {%>, tenantIdentification()<%}%>).if(ctx => publicRoute(ctx))) 65 | <%_ if(addTracing){ _%> 66 | tracingEnabled && app.use(tracingMiddleware()) 67 | <%_}_%> 68 | app.use( 69 | koaMiddleware(apolloServer,{ 70 | context: async ({ ctx }) => { 71 | const { token, state, <% if(withMultiTenancy){ %>tenant, <%}%>externalUser, request, requestSpan } = ctx; 72 | const { cache } = apolloServer 73 | const dataSources = getDataSources({ ...ctx, cache }) 74 | return { 75 | token, 76 | state, 77 | <%_ if(withMultiTenancy){ _%> 78 | tenant, 79 | <%_}_%> 80 | externalUser, 81 | request, 82 | requestSpan, 83 | logger, 84 | dataSources 85 | } 86 | } 87 | }) 88 | ) 89 | 90 | httpServer.on('request', app.callback()) 91 | 92 | return apolloServer 93 | }; 94 | 95 | module.exports = { startApolloServer, plugins }; 96 | -------------------------------------------------------------------------------- /generators/app/questions.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | 3 | export const projectNameQ = { 4 | type: 'input', 5 | name: 'projectName', 6 | message: 'What is the name of your project?', 7 | validate: appName => { 8 | const pass = appName.match(/^((?!-)[A-Za-z-._\d]{1,63}(? [ 29 | { 30 | type: 'confirm', 31 | name: 'withRights', 32 | message: 33 | 'Would you like to use and implement custom authorization for your GraphQL schema? This includes rights and permissions.', 34 | default: false 35 | }, 36 | { 37 | type: 'confirm', 38 | name: 'withMultiTenancy', 39 | message: 'Would you like to use and implement multi-tenancy?', 40 | default: false 41 | }, 42 | { 43 | type: 'confirm', 44 | name: 'hasSharedDb', 45 | when: prompts => prompts.withMultiTenancy, 46 | message: 'Do you have a database that is shared by multiple tenants?', 47 | default: false 48 | }, 49 | { 50 | type: 'input', 51 | name: 'dbConnectionName', 52 | when: prompts => prompts.withMultiTenancy, 53 | message: 'What is your database connection name?', 54 | default: 'myDatabase' 55 | }, 56 | { 57 | type: 'confirm', 58 | name: 'addSubscriptions', 59 | message: 'Would you like to support subscriptions?', 60 | default: false 61 | }, 62 | { 63 | type: 'confirm', 64 | name: 'addMessaging', 65 | message: 'Would you like to use messaging? This will allow you to react to messages in the event-driven fashion.', 66 | default: false 67 | }, 68 | { 69 | type: 'list', 70 | name: 'messagingTransport', 71 | message: 72 | 'What messaging transport would you like to use? Read more here: https://github.com/osstotalsoft/nodebb/tree/master/packages/message-bus#transport.', 73 | choices: ['nats', 'rusi'], 74 | when: prompts => prompts.addMessaging, 75 | default: 'nats' 76 | }, 77 | { 78 | type: 'confirm', 79 | name: 'addHelm', 80 | message: 'Would you like to generate the default helm files?', 81 | default: false 82 | }, 83 | { 84 | type: 'input', 85 | name: 'helmChartName', 86 | message: 'What is the name of your helm chart?', 87 | when: prompts => prompts.addHelm, 88 | validate: name => { 89 | const pass = name.match(/^((?!-)[A-Za-z-._\d]{1,63}(? projectName.toLowerCase().replace('_', '-') 99 | }, 100 | { 101 | type: 'confirm', 102 | name: 'addTracing', 103 | message: 'Would you like to add opentracing and integration with Jaeger?', 104 | default: false 105 | }, 106 | { 107 | type: 'confirm', 108 | name: 'addQuickStart', 109 | message: 'Would you like to include quick start examples?', 110 | default: false 111 | } 112 | ] 113 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/src/startup/tracing.js: -------------------------------------------------------------------------------- 1 | const opentelemetry = require("@opentelemetry/sdk-node") 2 | const { Resource } = require("@opentelemetry/resources") 3 | const { JaegerPropagator } = require("@opentelemetry/propagator-jaeger") 4 | <%_ if(addSubscriptions) {_%> 5 | const { WSInstrumentation } = require("@totalsoft/opentelemetry-instrumentation-ws") 6 | const { IORedisInstrumentation } = require('@opentelemetry/instrumentation-ioredis') 7 | <%_} _%> 8 | const { SEMRESATTRS_SERVICE_NAME, SEMATTRS_PEER_SERVICE } = require('@opentelemetry/semantic-conventions') 9 | const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc') 10 | const { ParentBasedSampler, AlwaysOnSampler } = require('@opentelemetry/sdk-trace-node') 11 | const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http') 12 | const { PinoInstrumentation } = require('@opentelemetry/instrumentation-pino') 13 | const { DataloaderInstrumentation } = require("@opentelemetry/instrumentation-dataloader"); 14 | const { GraphQLInstrumentation } = require('@opentelemetry/instrumentation-graphql') 15 | const { context, trace } = require('@opentelemetry/api') 16 | const { getRPCMetadata, RPCType } = require('@opentelemetry/core') 17 | const instrumentation = require('@opentelemetry/instrumentation') 18 | const { PrismaInstrumentation } = require("@prisma/instrumentation") 19 | 20 | const { OTEL_SERVICE_NAME, OTEL_TRACE_PROXY , OTEL_TRACING_ENABLED} = process.env 21 | const otelTraceProxy = JSON.parse(OTEL_TRACE_PROXY || 'false') 22 | 23 | class CustomGraphQLInstrumentation extends GraphQLInstrumentation { 24 | init() { 25 | const module = new instrumentation.InstrumentationNodeModuleDefinition('graphql', ['>=14']) 26 | module.files.push(this._addPatchingExecute()) 27 | return module 28 | } 29 | } 30 | 31 | const isGraphQLRoute = url => url?.startsWith('/graphql') 32 | const isTelemetryRoute = url => url?.startsWith('/metrics') || url?.startsWith('/livez') || url?.startsWith('/readyz') 33 | 34 | // configure the SDK to export telemetry data to the console 35 | // enable all auto-instrumentations from the meta package 36 | const traceExporter = new OTLPTraceExporter() 37 | const tracingEnabled = JSON.parse(OTEL_TRACING_ENABLED || 'false') 38 | const sdk = tracingEnabled && new opentelemetry.NodeSDK({ 39 | resource: new Resource({ 40 | [SEMRESATTRS_SERVICE_NAME.SERVICE_NAME]: OTEL_SERVICE_NAME 41 | }), 42 | sampler: new ParentBasedSampler({ root: new AlwaysOnSampler() }), 43 | traceExporter, 44 | textMapPropagator: new JaegerPropagator(), 45 | instrumentations: [ 46 | new HttpInstrumentation({ 47 | ignoreIncomingRequestHook: r => 48 | r.method == 'OPTIONS' || (otelTraceProxy ? isTelemetryRoute(r.url) : !isGraphQLRoute(r.url)), 49 | ignoreOutgoingRequestHook: _ => !trace.getSpan(context.active()), // ignore outgoing requests without parent span 50 | startOutgoingSpanHook: r => ({ [SEMATTRS_PEER_SERVICE]: r.host || r.hostname }) 51 | }), 52 | new CustomGraphQLInstrumentation({ 53 | ignoreTrivialResolveSpans: true, 54 | mergeItems: true, 55 | responseHook: (span, _) => { 56 | const rpcMetadata = getRPCMetadata(context.active()) 57 | if (rpcMetadata?.type === RPCType.HTTP) { 58 | rpcMetadata?.span?.updateName(`${rpcMetadata?.span?.name} ${span.name}`) 59 | } 60 | } 61 | }), 62 | new PinoInstrumentation(), 63 | new DataloaderInstrumentation(), 64 | <%_ if(addSubscriptions) {_%> 65 | new IORedisInstrumentation(), 66 | new WSInstrumentation(), 67 | <%_} _%> 68 | new PrismaInstrumentation() 69 | ] 70 | }); 71 | 72 | 73 | function start({ logger = console } = {}) { 74 | // initialize the SDK and register with the OpenTelemetry API 75 | // this enables the API to record telemetry 76 | try { 77 | sdk.start() 78 | logger.info('Tracing initialized') 79 | } catch (error) { 80 | logger.error(error, 'Error initializing tracing') 81 | } 82 | } 83 | 84 | function shutdown({ logger = console } = {}) { 85 | return sdk 86 | .shutdown() 87 | .then(() => logger.info("Tracing terminated")) 88 | .catch(error => logger.error(error, "Error terminating tracing")); 89 | } 90 | 91 | module.exports = { start, shutdown }; 92 | -------------------------------------------------------------------------------- /generators/__tests__/app.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-unpublished-import */ 2 | 'use strict' 3 | import path, { dirname } from 'path' 4 | import assert from 'yeoman-assert' 5 | // eslint-disable-next-line node/no-missing-import 6 | import helpers from 'yeoman-test' 7 | import { fileURLToPath } from 'url' 8 | import { afterEach, describe, it } from 'mocha' 9 | import fs from 'fs-extra' 10 | 11 | const __filename = fileURLToPath(import.meta.url) 12 | const __dirname = dirname(__filename) 13 | 14 | describe('generator-graphql-rocket:app', function () { 15 | this.timeout(10 * 1000) 16 | 17 | const projectName = 'test-graphql' 18 | const helmChartName = 'test-helm' 19 | const dbConnectionName = 'testDatabase' 20 | const defaultAnswers = { 21 | projectName, 22 | withMultiTenancy: false, 23 | hasSharedDb: false, 24 | dbConnectionName, 25 | addSubscriptions: false, 26 | addMessaging: false, 27 | withRights: false, 28 | addHelm: false, 29 | addTracing: false 30 | } 31 | 32 | afterEach(() => { 33 | const testDir = path.join(__dirname, projectName) 34 | if (fs.existsSync(testDir)) fs.removeSync(testDir) 35 | }) 36 | 37 | it('create new project folder with template data', () => 38 | helpers 39 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 40 | .withAnswers(defaultAnswers) 41 | .withOptions({ skipPackageInstall: true }) 42 | .catch(err => { 43 | assert.fail(err) 44 | }) 45 | .then(({ cwd }) => { 46 | assert.file(`${cwd}/${projectName}/src/index.js`) 47 | })) 48 | 49 | it('project has given name', () => 50 | helpers 51 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 52 | .withAnswers(defaultAnswers) 53 | .withOptions({ skipPackageInstall: true }) 54 | .catch(err => { 55 | assert.fail(err) 56 | }) 57 | .then(({ cwd }) => { 58 | assert.fileContent(`${cwd}/${projectName}/package.json`, `"name": "${projectName}"`) 59 | })) 60 | 61 | it('gql port is configured', () => 62 | helpers 63 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 64 | .withAnswers(defaultAnswers) 65 | .withOptions({ skipPackageInstall: true }) 66 | .catch(err => { 67 | assert.fail(err) 68 | }) 69 | .then(({ cwd }) => { 70 | assert.fileContent(`${cwd}/${projectName}/.env`, `PORT="4000"`) 71 | })) 72 | 73 | it('Redis PubSub is added for Subscriptions', () => 74 | helpers 75 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 76 | .withAnswers({ 77 | ...defaultAnswers, 78 | addSubscriptions: true 79 | }) 80 | .withOptions({ skipPackageInstall: true }) 81 | .catch(err => { 82 | assert.fail(err) 83 | }) 84 | .then(({ cwd }) => { 85 | assert.file(`${cwd}/${projectName}/src/pubSub/pubSub.js`) 86 | })) 87 | 88 | it('helm files are added when addHelm option is true', () => 89 | helpers 90 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 91 | .withAnswers({ projectName }) 92 | .withAnswers({ 93 | ...defaultAnswers, 94 | addHelm: true, 95 | helmChartName 96 | }) 97 | .withOptions({ skipPackageInstall: true }) 98 | .catch(err => { 99 | assert.fail(err) 100 | }) 101 | .then(({ cwd }) => { 102 | assert.file(`${cwd}/${projectName}/helm`) 103 | })) 104 | 105 | it('Permissions and rights are ready to be used', () => 106 | helpers 107 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 108 | .withAnswers({ 109 | ...defaultAnswers, 110 | withRights: true 111 | }) 112 | .withOptions({ skipPackageInstall: true }) 113 | .catch(err => { 114 | assert.fail(err) 115 | }) 116 | .then(({ cwd }) => { 117 | const root = `${cwd}/${projectName}/src/middleware/permissions` 118 | assert.file([`${root}/index.js`, `${root}/rules.js`]) 119 | })) 120 | 121 | it('Rusi messaging transport', () => 122 | helpers 123 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 124 | .withAnswers({ 125 | ...defaultAnswers, 126 | addMessaging: true, 127 | messagingTransport: 'rusi', 128 | addHelm: true, 129 | helmChartName 130 | }) 131 | .withOptions({ skipPackageInstall: true }) 132 | .catch(err => { 133 | assert.fail(err) 134 | }) 135 | .then(({ cwd }) => { 136 | const valuesYaml = `${cwd}/${projectName}/helm/${helmChartName}/values.yaml` 137 | assert.fileContent([[valuesYaml, `transport: "rusi"`]]) 138 | 139 | const deploymentYaml = `${cwd}/${projectName}/helm/${helmChartName}/templates/deployment.yaml` 140 | 141 | assert.fileContent([ 142 | [deploymentYaml, `rusi.io/app-id: {{ $current.messaging.source | quote }}`], 143 | [deploymentYaml, `rusi.io/enabled: {{ lower $global.messaging.transport | eq "rusi" | quote }}`] 144 | ]) 145 | })) 146 | }) 147 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/helm/gql/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | {{- $current := .Values.gql -}} 2 | {{- $global := .Values.global -}} 3 | {{- if $current.enabled -}} 4 | 5 | apiVersion: apps/v1 6 | kind: Deployment 7 | metadata: 8 | name: {{ include "Gql.fullname" . }} 9 | labels: 10 | app.kubernetes.io/name: {{ include "Gql.fullname" . }} 11 | helm.sh/chart: {{ include "Gql.chart" . }} 12 | app.kubernetes.io/instance: {{ .Release.Name }} 13 | app.kubernetes.io/managed-by: {{ .Release.Service }} 14 | sidecar.jaegertracing.io/inject: "false" 15 | spec: 16 | replicas: {{ $current.replicaCount }} 17 | selector: 18 | matchLabels: 19 | {{- include "Gql.selectorLabels" . | nindent 6 }} 20 | template: 21 | metadata: 22 | <%_ if(addMessaging) { _%> 23 | annotations: 24 | rusi.io/app-id: {{ $current.messaging.source | quote }} 25 | rusi.io/enabled: {{ lower $global.messaging.transport | eq "rusi" | quote }} 26 | <%_}_%> 27 | labels: 28 | {{- include "Gql.selectorLabels" . | nindent 8 }} 29 | spec: 30 | {{- with $current.image.imagePullSecrets }} 31 | imagePullSecrets: 32 | {{- toYaml . | nindent 8 }} 33 | {{- end }} 34 | containers: 35 | - name: {{ $current.name }} 36 | image: "{{ $current.image.repository }}{{ $current.image.name}}:{{ $current.image.tag | default .Chart.AppVersion }}" 37 | imagePullPolicy: {{ $current.image.pullPolicy }} 38 | ports: 39 | - name: http 40 | containerPort: {{ $current.service.targetPort }} 41 | protocol: TCP 42 | {{- if $global.diagnostics.enabled }} 43 | - name: diag 44 | containerPort: {{ $global.diagnostics.port }} 45 | protocol: TCP 46 | {{- end }} 47 | {{- if $global.metrics.enabled }} 48 | - name: metrics 49 | containerPort: {{ $global.metrics.port }} 50 | protocol: TCP 51 | {{- end }} 52 | resources: 53 | {{- toYaml $current.resources | trim | nindent 12 }} 54 | env: 55 | <%_ if(addMessaging) { _%> 56 | - name: Messaging__Env 57 | value: {{ $global.messaging.env | quote }} 58 | - name: Messaging__Source 59 | value: {{ $current.messaging.source | quote }} 60 | - name: Messaging__Transport 61 | value: {{ $global.messaging.transport | quote }} 62 | - name: NATS_URL 63 | value: {{ $global.messaging.natsUrl | quote }} 64 | - name: NATS_CLUSTER 65 | value: {{ $global.messaging.natsCluster | quote }} 66 | - name: NATS_DURABLE_NAME 67 | value: {{ $global.messaging.natsDurableName | quote }} 68 | - name: NATS_CLIENT_ID 69 | value: {{ $current.messaging.clientId | quote }} 70 | - name: NATS_Q_GROUP 71 | value: {{ $current.messaging.natsQGroup | quote }} 72 | <%_}_%> 73 | - name: DIAGNOSTICS_ENABLED 74 | value: {{ $global.diagnostics.enabled | quote }} 75 | - name: DIAGNOSTICS_PORT 76 | value: {{ $global.diagnostics.port | quote }} 77 | - name: METRICS_ENABLED 78 | value: {{ $global.metrics.enabled | quote }} 79 | - name: METRICS_PORT 80 | value: {{ $global.metrics.port | quote }} 81 | <%_ if(addTracing){ _%> 82 | - name: OTEL_TRACING_ENABLED 83 | value: {{ $global.otlp.enabled | quote }} 84 | - name: OTEL_EXPORTER_OTLP_ENDPOINT 85 | value: {{ $global.otlp.endpoint | quote }} 86 | <%_}_%> 87 | <%_ if(withMultiTenancy){ _%> 88 | - name: IS_MULTITENANT 89 | value: {{ $global.multiTenancy.tenancyType | eq "MultiTenant" | quote }} 90 | <%_}_%> 91 | {{- range $key, $value := $current.env }} 92 | - name: {{ $key }} 93 | value: {{ $value | quote }} 94 | {{- end }} 95 | {{- if $global.runtimeConfiguration.enabled }} 96 | volumeMounts: 97 | - name: runtime-configs 98 | readOnly: true 99 | mountPath: /usr/src/app/runtime/configs 100 | - name: runtime-secrets 101 | readOnly: true 102 | mountPath: /usr/src/app/runtime/secrets 103 | {{- end }} 104 | {{- if $global.imagePullSecrets }} 105 | imagePullSecrets: 106 | {{- toYaml $global.imagePullSecrets | trim | nindent 8 }} 107 | {{- end }} 108 | {{- if $global.runtimeConfiguration.enabled }} 109 | volumes: 110 | - name: runtime-configs 111 | configMap: 112 | name: {{ $global.runtimeConfiguration.configMap }} 113 | defaultMode: 420 114 | - name: runtime-secrets 115 | csi: 116 | driver: secrets-store.csi.k8s.io 117 | readOnly: true 118 | volumeAttributes: 119 | secretProviderClass: {{ $global.runtimeConfiguration.csi.secretProviderClass }} 120 | {{- end }} 121 | {{- with $current.nodeSelector }} 122 | nodeSelector: 123 | {{- toYaml . | nindent 8 }} 124 | {{- end }} 125 | {{- end -}} 126 | -------------------------------------------------------------------------------- /generators/app/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | import Generator from 'yeoman-generator' 3 | // require('lodash'.extend(Generator.prototype, require('yeoman-generator/lib/actions/install' 4 | import chalk from 'chalk' 5 | import yosay from 'yosay' 6 | import path from 'path' 7 | import { concat, mergeLeft } from 'ramda' 8 | import { projectNameQ, getQuestions, usePrevConfigsQ } from './questions.js' 9 | import { checkForLatestVersion, getCurrentVersion } from '../utils.js' 10 | import { YO_RC_FILE } from './constants.js' 11 | 12 | export default class extends Generator { 13 | constructor(args, opts) { 14 | super(args, { ...opts, skipRegenerate: true, ignoreWhitespace: true, force: true, skipLocalCache: false }) 15 | } 16 | 17 | async prompting() { 18 | this.isLatest = await checkForLatestVersion() 19 | 20 | this.log( 21 | yosay(`Welcome to the fantabulous ${chalk.red('TotalSoft GraphQL Server')} generator! (⌐■_■) 22 | Out of the box I include Apollo Server, Koa and token validation.`) 23 | ) 24 | this.answers = await this.prompt(projectNameQ) 25 | const { projectName } = this.answers 26 | this.destinationRoot(path.join(this.contextRoot, `/${projectName}`)) 27 | 28 | if (this.fs.exists(path.join(this.destinationPath(), `/${YO_RC_FILE}`))) 29 | this.answers = mergeLeft(this.answers, await this.prompt(usePrevConfigsQ)) 30 | 31 | this.config.set('__TIMESTAMP__', new Date().toLocaleString()) 32 | this.config.set('__VERSION__', await getCurrentVersion()) 33 | 34 | const questions = getQuestions(projectName) 35 | const { usePrevConfigs } = this.answers 36 | this.answers = usePrevConfigs 37 | ? mergeLeft(this.answers, await this.prompt(questions, this.config)) 38 | : mergeLeft(this.answers, await this.prompt(questions)) 39 | 40 | questions.forEach(q => this.config.set(q.name, this.answers[q.name])) 41 | } 42 | 43 | writing() { 44 | if (!this.isLatest) return 45 | 46 | const { 47 | addSubscriptions, 48 | addMessaging, 49 | addHelm, 50 | withMultiTenancy, 51 | hasSharedDb, 52 | addTracing, 53 | withRights, 54 | helmChartName, 55 | addQuickStart 56 | } = this.answers 57 | 58 | const templatePath = this.templatePath('infrastructure/**/*') 59 | const destinationPath = this.destinationPath() 60 | 61 | let ignoreFiles = ['**/.npmignore', '**/.gitignore-template', '**/helm/**'] 62 | 63 | if (!addSubscriptions) 64 | ignoreFiles = concat(['**/pubSub/**', '**/subscriptions/**', '**/servers/subscription.js'], ignoreFiles) 65 | if (!addMessaging) ignoreFiles = concat(['**/messaging/**', '**/servers/messaging.js'], ignoreFiles) 66 | 67 | if (!withMultiTenancy) 68 | ignoreFiles = concat( 69 | [ 70 | '**/features/tenant/**', 71 | '**/multiTenancy/**', 72 | '**/middleware/tenantIdentification/**', 73 | '**/subscriptions/middleware/tenantContext.js', 74 | '**/prisma/tenancyExtension.js', 75 | '**/pubSub/middleware/tenantPublish.js' 76 | ], 77 | ignoreFiles 78 | ) 79 | if (!hasSharedDb) 80 | ignoreFiles = concat(['**/db/multiTenancy/tenancyFilter.js', '**/prisma/tenancyExtension.js'], ignoreFiles) 81 | if (!addTracing) 82 | ignoreFiles = concat( 83 | [ 84 | '**/tracing/**', 85 | '**/startup/tracing.js**', 86 | '**/startup/middleware/tracing.js', 87 | '**/pubSub/middleware/tracingPublish.js', 88 | '**/subscriptions/middleware/tracing.js' 89 | ], 90 | ignoreFiles 91 | ) 92 | if (!withRights) 93 | ignoreFiles = concat( 94 | ['**/middleware/permissions/**', '**/constants/permissions.js', '**/constants/identityUserRoles.js'], 95 | ignoreFiles 96 | ) 97 | 98 | if (!addQuickStart) 99 | ignoreFiles = concat( 100 | [ 101 | '**/features/common/dbGenerators.js', 102 | '**/features/tenant/**', 103 | '**/features/user/**', 104 | '**/constants/identityUserRoles.js', 105 | '**/middleware/permissions/__tests__/**', 106 | '**/README.md' 107 | ], 108 | ignoreFiles 109 | ) 110 | 111 | this.fs.copyTpl(templatePath, destinationPath, this.answers, {}, { globOptions: { ignore: ignoreFiles, dot: true } }) 112 | 113 | const gitignorePath = this.templatePath('infrastructure/.gitignore-template') 114 | const gitignoreDestinationPath = path.join(destinationPath, `/.gitignore`) 115 | this.fs.copy(gitignorePath, gitignoreDestinationPath) 116 | 117 | if (addHelm) { 118 | const helmTemplatePath = this.templatePath('infrastructure/helm/gql/**') 119 | const helmDestinationPath = path.join(destinationPath, `/helm/${helmChartName}`) 120 | this.fs.copyTpl(helmTemplatePath, helmDestinationPath, this.answers, {}, { globOptions: { dot: true } }) 121 | } 122 | } 123 | 124 | install() { 125 | if (!this.isLatest || this?.options?.skipPackageInstall) return 126 | 127 | this.log(chalk.greenBright(`All the dependencies will be installed shortly using "npm" package manager...`)) 128 | this.spawnCommandSync('npm install') 129 | this.spawnCommandSync('npx prettier --write **/*.{js,jsx,ts,tsx,css,scss,md,json}') 130 | } 131 | 132 | end() { 133 | if (!this.isLatest) return 134 | 135 | this.log( 136 | yosay(`Congratulations, you just entered the exciting world of GraphQL! Enjoy! 137 | Bye now! 138 | (*^_^*)`) 139 | ) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /generators/app/templates/infrastructure/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "<%= projectName.toLowerCase() %>", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "type": "commonjs", 7 | "engines": { 8 | "node": ">=20.x", 9 | "npm": ">= 9.x" 10 | }, 11 | "scripts": { 12 | "postinstall": "npx -y sort-package-json", 13 | "setcodepage": "run-script-os", 14 | "setcodepage:linux": "", 15 | "setcodepage:windows": "chcp 65001", 16 | "start": "npm run setcodepage && npm run lint && cross-env NODE_ENV=development nodemon src/index.js", 17 | "start:production": "cross-env NODE_ENV=production node src/index.js", 18 | "test": "jest --collectCoverage --passWithNoTests", 19 | "test:watchAll": "npm run test -- --watchAll", 20 | "test:watch": "npm run test -- --watch", 21 | "test:ci": "cross-env CI=true npm test -- --reporters=default --reporters=jest-junit --coverage --coverageReporters=cobertura --coverageReporters=lcov --coverageReporters=html", 22 | "lint": "eslint .", 23 | "prettier": "prettier --write **/*.{js,jsx,ts,tsx,css,md,json}", 24 | "prepack": "husky install", 25 | "prisma": "npx prisma db pull && npm run prisma:format && npx prisma generate", 26 | "prisma:format": "npx prisma-case-format --file ./prisma/schema.prisma --table-case pascal --field-case camel" 27 | }, 28 | "lint-staged": { 29 | "**/*.+(js|md|css|graphql|json)": "prettier --write" 30 | }, 31 | "author": "", 32 | "license": "ISC", 33 | "dependencies": { 34 | "@apollo/datasource-rest": "^6.3.0", 35 | "@apollo/server": "^4.11.2", 36 | "@as-integrations/koa": "^1.1.1", 37 | "@graphql-tools/load-files": "^7.0.0", 38 | "@graphql-tools/merge": "^9.0.8", 39 | "@graphql-tools/schema": "^10.0.7", 40 | "@koa/cors": "^5.0.0", 41 | "koa-ignore": "^1.0.1", 42 | "@prisma/client": "^5.22.0", 43 | "prisma": "^5.22.0", 44 | "prisma-case-format": "^2.2.1", 45 | <%_ if(addTracing){ _%> 46 | "@prisma/instrumentation": "^5.22.0", 47 | <%_}_%> 48 | "@totalsoft/key-per-file-configuration": "^2.0.0", 49 | "@totalsoft/graceful-shutdown": "^2.0.0", 50 | "@totalsoft/correlation": "^3.0.0", 51 | "@totalsoft/pino-apollo": "^3.0.1", 52 | "@totalsoft/pino-correlation": "^2.0.1", 53 | "@totalsoft/pino-mssqlserver": "^2.0.1", 54 | <%_ if(withMultiTenancy) {_%> 55 | "@totalsoft/multitenancy-core": "^2.0.0", 56 | "@totalsoft/pino-multitenancy": "^2.0.0", 57 | <%_}_%> 58 | <%_ if(addMessaging || (withMultiTenancy && addSubscriptions)) {_%> 59 | "@totalsoft/message-bus": "^2.5.0", 60 | "@totalsoft/messaging-host": "^2.5.0", 61 | <%_}_%> 62 | "bluebird": "^3.7.2", 63 | "@totalsoft/metrics": "2.0.0", 64 | "console-stamp": "^3.1.2", 65 | "dotenv": "^16.4.5", 66 | "graphql": "^16.9.0", 67 | <%_ if(addSubscriptions){ _%> 68 | "graphql-redis-subscriptions": "^2.6.1", 69 | "graphql-subscriptions": "^2.0.0", 70 | "graphql-ws": "^5.16.0", 71 | "ioredis": "^5.4.1", 72 | "ws": "^8.18.0", 73 | <%_ if(addTracing){ _%> 74 | "@opentelemetry/instrumentation-ioredis": "^0.44.0", 75 | "@totalsoft/opentelemetry-instrumentation-ws": "^2.0.0", 76 | <%_}_%> 77 | <%_}_%> 78 | <%_ if(withRights){ _%> 79 | "graphql-middleware": "^6.1.35", 80 | "graphql-shield": "7.6.2", 81 | <%_}_%> 82 | "humps": "^2.0.1", 83 | "@opentelemetry/api": "^1.9.0", 84 | "@opentelemetry/exporter-prometheus": "^0.54.1", 85 | "@opentelemetry/sdk-metrics": "^1.27.0", 86 | <%_ if(addTracing){ _%> 87 | "@opentelemetry/core": "^1.27.0", 88 | "@opentelemetry/exporter-trace-otlp-grpc": "^0.54.1", 89 | "@opentelemetry/instrumentation": "^0.54.1", 90 | "@opentelemetry/instrumentation-dataloader": "^0.13.0", 91 | "@opentelemetry/instrumentation-graphql": "^0.44.0", 92 | "@opentelemetry/instrumentation-http": "^0.54.1", 93 | "@opentelemetry/instrumentation-pino": "^0.43.0", 94 | "@opentelemetry/propagator-jaeger": "^1.27.0", 95 | "@opentelemetry/resources": "^1.27.0", 96 | "@opentelemetry/sdk-node": "^0.54.1", 97 | "@opentelemetry/sdk-trace-node": "^1.27.0", 98 | "@opentelemetry/semantic-conventions": "^1.27.0", 99 | "@totalsoft/pino-opentelemetry": "^3.0.0", 100 | <%_}_%> 101 | "jsonwebtoken": "9.0.2", 102 | "jwks-rsa": "^3.1.0", 103 | "koa": "^2.15.3", 104 | "koa-bodyparser": "^4.4.1", 105 | "koa-jwt": "^4.0.4", 106 | "numeral": "^2.0.6", 107 | "path": "^0.12.7", 108 | "ramda": "^0.30.1", 109 | "tedious": "^18.6.1", 110 | "uuid": "^11.0.2", 111 | "pino": "^9.5.0", 112 | "pino-abstract-transport": "^2.0.0", 113 | "pino-pretty": "^11.3.0" 114 | }, 115 | "devDependencies": { 116 | "@eslint/compat": "^1.2.2", 117 | "@eslint/eslintrc": "^3.1.0", 118 | "@eslint/js": "^9.14.0", 119 | "cross-env": "7.0.3", 120 | "eslint": "^9.14.0", 121 | "eslint-plugin-import": "^2.31.0", 122 | "eslint-plugin-jest": "^28.9.0", 123 | "eslint-plugin-node": "^11.1.0", 124 | "husky": "^9.1.6", 125 | "jest": "^29.7.0", 126 | "jest-extended": "^4.0.2", 127 | "jest-junit": "^16.0.0", 128 | "lint-staged": "^15.2.10", 129 | "nodemon": "^3.1.7", 130 | "prettier": "^3.3.3", 131 | "run-script-os": "^1.1.6" 132 | }, 133 | "jest": { 134 | "setupFilesAfterEnv": [ 135 | "jest-extended" 136 | ] 137 | }, 138 | "nodemonConfig": { 139 | "ext": "js,mjs,json,graphql" 140 | }, 141 | "prisma": { 142 | "seed": "node prisma/seed.js" 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /generators/__tests__/answers.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-unpublished-import */ 2 | 'use strict' 3 | import path, { dirname } from 'path' 4 | import assert from 'yeoman-assert' 5 | // eslint-disable-next-line node/no-missing-import 6 | import helpers from 'yeoman-test' 7 | import { projectNameQ, getQuestions } from '../app/questions.js' 8 | import { findIndex } from 'ramda' 9 | import { expect } from 'chai' 10 | import { fileURLToPath } from 'url' 11 | import { afterEach, describe, it } from 'mocha' 12 | import fs from 'fs-extra' 13 | 14 | const __filename = fileURLToPath(import.meta.url) 15 | const __dirname = dirname(__filename) 16 | 17 | describe('generator-graphql-rocket:app question validations', function () { 18 | it('project name input does not have an acceptable format', function () { 19 | const name = '& - a!' 20 | const validationResult = projectNameQ.validate(name) 21 | expect(validationResult).to.not.equal(true) 22 | }) 23 | 24 | it('project name input has an acceptable format', function () { 25 | const name = 'my-project_a' 26 | const validationResult = projectNameQ.validate(name) 27 | expect(validationResult).to.equal(true) 28 | }) 29 | 30 | it('helm chart name input does not have an acceptable format', function () { 31 | const name = '& - a!' 32 | const questions = getQuestions('test') 33 | const qIndex = findIndex(q => q.name === 'helmChartName', questions) 34 | const validationResult = questions[qIndex].validate(name) 35 | expect(validationResult).to.not.equal(true) 36 | }) 37 | 38 | it('helm chart name input has an acceptable format', function () { 39 | const name = 'my-chart_a' 40 | const questions = getQuestions('test') 41 | const qIndex = findIndex(q => q.name === 'helmChartName', questions) 42 | const validationResult = questions[qIndex].validate(name) 43 | expect(validationResult).to.equal(true) 44 | }) 45 | }) 46 | 47 | describe('test project generation', function () { 48 | this.timeout(10 * 1000) 49 | 50 | const projectName = 'test-graphql' 51 | const dbConnectionName = 'testDatabase' 52 | const messageBus = /^@totalsoft[/]message-bus/ 53 | const defaultAnswers = { 54 | projectName, 55 | withMultiTenancy: false, 56 | hasSharedDb: false, 57 | dbConnectionName, 58 | addSubscriptions: false, 59 | addMessaging: false, 60 | withRights: false, 61 | addHelm: false, 62 | addTracing: false 63 | } 64 | 65 | afterEach(() => { 66 | const testDir = path.join(__dirname, projectName) 67 | if (fs.existsSync(testDir)) fs.removeSync(testDir) 68 | }) 69 | 70 | it('does not contain subscriptions', () => 71 | helpers 72 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 73 | .withAnswers({ 74 | ...defaultAnswers, 75 | addSubscriptions: false 76 | }) 77 | .withOptions({ skipPackageInstall: true }) 78 | .catch(err => { 79 | assert.fail(err) 80 | }) 81 | .then(({ cwd }) => { 82 | assert.noFile(`${cwd}/${projectName}/src/pubSub/pubSub.js`) 83 | })) 84 | 85 | it('does not contain messaging', () => 86 | helpers 87 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 88 | .withAnswers({ 89 | ...defaultAnswers, 90 | addMessaging: false 91 | }) 92 | .withOptions({ skipPackageInstall: true }) 93 | .catch(err => { 94 | assert.fail(err) 95 | }) 96 | .then(({ cwd }) => { 97 | console.log(`Evaluating in: ${cwd}`) 98 | assert.noFile(`${cwd}/${projectName}/src/messaging/index.js`) 99 | })) 100 | 101 | it('does not contain message-bus package', () => 102 | helpers 103 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 104 | .withAnswers({ 105 | ...defaultAnswers 106 | }) 107 | .withOptions({ skipPackageInstall: true }) 108 | .then(({ cwd }) => { 109 | assert.jsonFileContent(`${cwd}/${projectName}/package.json`, { 110 | dependencies: messageBus 111 | }) 112 | })) 113 | 114 | it('does not contain middleware in messaging', () => 115 | helpers 116 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 117 | .withAnswers({ 118 | ...defaultAnswers, 119 | addMessaging: false 120 | }) 121 | .withOptions({ skipPackageInstall: true }) 122 | .then(({ cwd }) => { 123 | assert.noFile(`${cwd}/${projectName}/src/messaging/middleware`) 124 | })) 125 | 126 | it('does not contain helm files', () => 127 | helpers 128 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 129 | .withAnswers({ 130 | ...defaultAnswers, 131 | addHelm: false 132 | }) 133 | .withOptions({ skipPackageInstall: true }) 134 | .then(({ cwd }) => { 135 | assert.noFile(`${cwd}/${projectName}/helm`) 136 | })) 137 | 138 | it('does not contain knex config files and db associated files', () => 139 | helpers 140 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 141 | .withAnswers(defaultAnswers) 142 | .withOptions({ skipPackageInstall: true }) 143 | .then(({ cwd }) => { 144 | assert.noFile([ 145 | `${cwd}/${projectName}/src/db`, 146 | `${cwd}/${projectName}/src/middleware/db`, 147 | `${cwd}/${projectName}/src/middleware/tenantIdentification`, 148 | `${cwd}/${projectName}/src/middleware/messaging/multiTenancy`, 149 | `${cwd}/${projectName}/src/messaging/middleware/dbInstance.js`, 150 | `${cwd}/${projectName}/src/startup/dataLoaders.js`, 151 | `${cwd}/${projectName}/src/features/common/dbGenerators.js`, 152 | `${cwd}/${projectName}/src/features/tenant`, 153 | `${cwd}/${projectName}/src/features/user/dataLoaders.js`, 154 | `${cwd}/${projectName}/src/features/user/dataSources/userDb.js`, 155 | `${cwd}/${projectName}/src/tracing/knexTracer.js`, 156 | `${cwd}/${projectName}/src/utils/sqlDataSource.js` 157 | ]) 158 | })) 159 | 160 | it('initializes prisma', () => 161 | helpers 162 | .run(path.join(__dirname, '../app'), { cwd: __dirname }) 163 | .withAnswers(defaultAnswers) 164 | .withOptions({ skipPackageInstall: true }) 165 | .then(({ cwd }) => { 166 | assert.file([ 167 | `${cwd}/${projectName}/prisma`, 168 | `${cwd}/${projectName}/src/prisma/client.js`, 169 | `${cwd}/${projectName}/src/prisma/index.d.ts`, 170 | `${cwd}/${projectName}/src/prisma/index.js` 171 | ]) 172 | assert.fileContent( 173 | `${cwd}/${projectName}/.env`, 174 | `DATABASE_URL="sqlserver://serverName:1433;database=databaseName;user=userName;password=password;trustServerCertificate=true"` 175 | ) 176 | })) 177 | }) 178 | --------------------------------------------------------------------------------