From f21c5ad4865adc1e96d7d08bf26f6bc3e075254b Mon Sep 17 00:00:00 2001 From: Justin Levi Winter Date: Thu, 23 Nov 2023 12:04:47 -0500 Subject: [PATCH] Update router.ts - adding operationId (#1349) * Update router.ts - adding operationId https://swagger.io/specification/#fixed-fields-8 operationId Unique string used to identify the operation. The id MUST be unique among all operations described in the API. The operationId value is case-sensitive. Tools and libraries MAY use the operationId to uniquely identify an operation, therefore, it is RECOMMENDED to follow common programming naming conventions. * Use operationName --------- Co-authored-by: Arda TANRIKULU --- .bob/cjs/src/ast.d.ts | 7 + .bob/cjs/src/ast.js | 16 ++ .bob/cjs/src/common.d.ts | 2 + .bob/cjs/src/common.js | 12 + .bob/cjs/src/index.d.ts | 5 + .bob/cjs/src/index.js | 11 + .bob/cjs/src/logger.d.ts | 6 + .bob/cjs/src/logger.js | 29 ++ .bob/cjs/src/open-api/index.d.ts | 26 ++ .bob/cjs/src/open-api/index.js | 62 +++++ .bob/cjs/src/open-api/operations.d.ts | 29 ++ .bob/cjs/src/open-api/operations.js | 164 +++++++++++ .bob/cjs/src/open-api/types.d.ts | 7 + .bob/cjs/src/open-api/types.js | 67 +++++ .bob/cjs/src/open-api/utils.d.ts | 3 + .bob/cjs/src/open-api/utils.js | 43 +++ .bob/cjs/src/parse.d.ts | 6 + .bob/cjs/src/parse.js | 46 +++ .bob/cjs/src/router.d.ts | 16 ++ .bob/cjs/src/router.js | 386 ++++++++++++++++++++++++++ .bob/cjs/src/sofa.d.ts | 57 ++++ .bob/cjs/src/sofa.js | 92 ++++++ .bob/cjs/src/subscriptions.d.ts | 33 +++ .bob/cjs/src/subscriptions.js | 160 +++++++++++ .bob/cjs/src/types.d.ts | 15 + .bob/cjs/src/types.js | 0 .bob/esm/src/ast.d.ts | 7 + .bob/esm/src/ast.js | 12 + .bob/esm/src/common.d.ts | 2 + .bob/esm/src/common.js | 7 + .bob/esm/src/index.d.ts | 5 + .bob/esm/src/index.js | 6 + .bob/esm/src/logger.d.ts | 6 + .bob/esm/src/logger.js | 25 ++ .bob/esm/src/open-api/index.d.ts | 26 ++ .bob/esm/src/open-api/index.js | 58 ++++ .bob/esm/src/open-api/operations.d.ts | 29 ++ .bob/esm/src/open-api/operations.js | 155 +++++++++++ .bob/esm/src/open-api/types.d.ts | 7 + .bob/esm/src/open-api/types.js | 62 +++++ .bob/esm/src/open-api/utils.d.ts | 3 + .bob/esm/src/open-api/utils.js | 37 +++ .bob/esm/src/parse.d.ts | 6 + .bob/esm/src/parse.js | 42 +++ .bob/esm/src/router.d.ts | 16 ++ .bob/esm/src/router.js | 382 +++++++++++++++++++++++++ .bob/esm/src/sofa.d.ts | 57 ++++ .bob/esm/src/sofa.js | 87 ++++++ .bob/esm/src/subscriptions.d.ts | 33 +++ .bob/esm/src/subscriptions.js | 156 +++++++++++ .bob/esm/src/types.d.ts | 15 + .bob/esm/src/types.js | 0 src/router.ts | 1 + 53 files changed, 2542 insertions(+) create mode 100644 .bob/cjs/src/ast.d.ts create mode 100644 .bob/cjs/src/ast.js create mode 100644 .bob/cjs/src/common.d.ts create mode 100644 .bob/cjs/src/common.js create mode 100644 .bob/cjs/src/index.d.ts create mode 100644 .bob/cjs/src/index.js create mode 100644 .bob/cjs/src/logger.d.ts create mode 100644 .bob/cjs/src/logger.js create mode 100644 .bob/cjs/src/open-api/index.d.ts create mode 100644 .bob/cjs/src/open-api/index.js create mode 100644 .bob/cjs/src/open-api/operations.d.ts create mode 100644 .bob/cjs/src/open-api/operations.js create mode 100644 .bob/cjs/src/open-api/types.d.ts create mode 100644 .bob/cjs/src/open-api/types.js create mode 100644 .bob/cjs/src/open-api/utils.d.ts create mode 100644 .bob/cjs/src/open-api/utils.js create mode 100644 .bob/cjs/src/parse.d.ts create mode 100644 .bob/cjs/src/parse.js create mode 100644 .bob/cjs/src/router.d.ts create mode 100644 .bob/cjs/src/router.js create mode 100644 .bob/cjs/src/sofa.d.ts create mode 100644 .bob/cjs/src/sofa.js create mode 100644 .bob/cjs/src/subscriptions.d.ts create mode 100644 .bob/cjs/src/subscriptions.js create mode 100644 .bob/cjs/src/types.d.ts create mode 100644 .bob/cjs/src/types.js create mode 100644 .bob/esm/src/ast.d.ts create mode 100644 .bob/esm/src/ast.js create mode 100644 .bob/esm/src/common.d.ts create mode 100644 .bob/esm/src/common.js create mode 100644 .bob/esm/src/index.d.ts create mode 100644 .bob/esm/src/index.js create mode 100644 .bob/esm/src/logger.d.ts create mode 100644 .bob/esm/src/logger.js create mode 100644 .bob/esm/src/open-api/index.d.ts create mode 100644 .bob/esm/src/open-api/index.js create mode 100644 .bob/esm/src/open-api/operations.d.ts create mode 100644 .bob/esm/src/open-api/operations.js create mode 100644 .bob/esm/src/open-api/types.d.ts create mode 100644 .bob/esm/src/open-api/types.js create mode 100644 .bob/esm/src/open-api/utils.d.ts create mode 100644 .bob/esm/src/open-api/utils.js create mode 100644 .bob/esm/src/parse.d.ts create mode 100644 .bob/esm/src/parse.js create mode 100644 .bob/esm/src/router.d.ts create mode 100644 .bob/esm/src/router.js create mode 100644 .bob/esm/src/sofa.d.ts create mode 100644 .bob/esm/src/sofa.js create mode 100644 .bob/esm/src/subscriptions.d.ts create mode 100644 .bob/esm/src/subscriptions.js create mode 100644 .bob/esm/src/types.d.ts create mode 100644 .bob/esm/src/types.js diff --git a/.bob/cjs/src/ast.d.ts b/.bob/cjs/src/ast.d.ts new file mode 100644 index 00000000..093c84af --- /dev/null +++ b/.bob/cjs/src/ast.d.ts @@ -0,0 +1,7 @@ +import { type DocumentNode, type OperationDefinitionNode, type VariableDefinitionNode } from 'graphql'; +export type OperationInfo = { + operation: OperationDefinitionNode; + variables: ReadonlyArray; + name: string; +} | undefined; +export declare function getOperationInfo(doc: DocumentNode): OperationInfo; diff --git a/.bob/cjs/src/ast.js b/.bob/cjs/src/ast.js new file mode 100644 index 00000000..7599d10e --- /dev/null +++ b/.bob/cjs/src/ast.js @@ -0,0 +1,16 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getOperationInfo = void 0; +const graphql_1 = require("graphql"); +function getOperationInfo(doc) { + const op = (0, graphql_1.getOperationAST)(doc, null); + if (!op) { + return; + } + return { + operation: op, + name: op.name.value, + variables: op.variableDefinitions || [], + }; +} +exports.getOperationInfo = getOperationInfo; diff --git a/.bob/cjs/src/common.d.ts b/.bob/cjs/src/common.d.ts new file mode 100644 index 00000000..910ba4e8 --- /dev/null +++ b/.bob/cjs/src/common.d.ts @@ -0,0 +1,2 @@ +export declare function convertName(name: string): string; +export declare function isNil(val: T): boolean; diff --git a/.bob/cjs/src/common.js b/.bob/cjs/src/common.js new file mode 100644 index 00000000..23dd67c9 --- /dev/null +++ b/.bob/cjs/src/common.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.isNil = exports.convertName = void 0; +const param_case_1 = require("param-case"); +function convertName(name) { + return (0, param_case_1.paramCase)(name); +} +exports.convertName = convertName; +function isNil(val) { + return val == null; +} +exports.isNil = isNil; diff --git a/.bob/cjs/src/index.d.ts b/.bob/cjs/src/index.d.ts new file mode 100644 index 00000000..b23585b4 --- /dev/null +++ b/.bob/cjs/src/index.d.ts @@ -0,0 +1,5 @@ +import type { SofaConfig } from './sofa'; +export { OpenAPI } from './open-api'; +export declare function useSofa(config: SofaConfig): import("fets").Router; diff --git a/.bob/cjs/src/index.js b/.bob/cjs/src/index.js new file mode 100644 index 00000000..a9922997 --- /dev/null +++ b/.bob/cjs/src/index.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.useSofa = exports.OpenAPI = void 0; +const router_1 = require("./router"); +const sofa_1 = require("./sofa"); +var open_api_1 = require("./open-api"); +Object.defineProperty(exports, "OpenAPI", { enumerable: true, get: function () { return open_api_1.OpenAPI; } }); +function useSofa(config) { + return (0, router_1.createRouter)((0, sofa_1.createSofa)(config)); +} +exports.useSofa = useSofa; diff --git a/.bob/cjs/src/logger.d.ts b/.bob/cjs/src/logger.d.ts new file mode 100644 index 00000000..28edbb6b --- /dev/null +++ b/.bob/cjs/src/logger.d.ts @@ -0,0 +1,6 @@ +export declare const logger: { + error: (...args: any[]) => void; + warn: (...args: any[]) => void; + info: (...args: any[]) => void; + debug: (...args: any[]) => void; +}; diff --git a/.bob/cjs/src/logger.js b/.bob/cjs/src/logger.js new file mode 100644 index 00000000..dd902de7 --- /dev/null +++ b/.bob/cjs/src/logger.js @@ -0,0 +1,29 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.logger = void 0; +const tslib_1 = require("tslib"); +const ansi_colors_1 = tslib_1.__importDefault(require("ansi-colors")); +const levels = ['error', 'warn', 'info', 'debug']; +const toLevel = (string) => levels.includes(string) ? string : null; +const currentLevel = globalThis.process?.env?.SOFA_DEBUG + ? 'debug' + : toLevel(globalThis.process?.env?.SOFA_LOGGER_LEVEL) ?? 'info'; +const log = (level, color, args) => { + if (levels.indexOf(level) <= levels.indexOf(currentLevel)) { + console.log(`${color(level)}:`, ...args); + } +}; +exports.logger = { + error: (...args) => { + log('error', ansi_colors_1.default.red, args); + }, + warn: (...args) => { + log('warn', ansi_colors_1.default.yellow, args); + }, + info: (...args) => { + log('info', ansi_colors_1.default.green, args); + }, + debug: (...args) => { + log('debug', ansi_colors_1.default.blue, args); + }, +}; diff --git a/.bob/cjs/src/open-api/index.d.ts b/.bob/cjs/src/open-api/index.d.ts new file mode 100644 index 00000000..721f772c --- /dev/null +++ b/.bob/cjs/src/open-api/index.d.ts @@ -0,0 +1,26 @@ +import { GraphQLSchema } from 'graphql'; +import { RouteInfo } from '../types'; +import { OpenAPIV3 } from 'openapi-types'; +export declare function OpenAPI({ schema, info, servers, components, security, tags, customScalars, }: { + schema: GraphQLSchema; + info: OpenAPIV3.InfoObject; + servers?: OpenAPIV3.ServerObject[]; + components?: Record; + security?: OpenAPIV3.SecurityRequirementObject[]; + tags?: OpenAPIV3.TagObject[]; + /** + * Override mapping of custom scalars to OpenAPI + * @example + * ```js + * { + * Date: { type: "string", format: "date" } + * } + * ``` + */ + customScalars?: Record; +}): { + addRoute(info: RouteInfo, config?: { + basePath?: string; + }): void; + get(): OpenAPIV3.Document<{}>; +}; diff --git a/.bob/cjs/src/open-api/index.js b/.bob/cjs/src/open-api/index.js new file mode 100644 index 00000000..0e100708 --- /dev/null +++ b/.bob/cjs/src/open-api/index.js @@ -0,0 +1,62 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.OpenAPI = void 0; +const graphql_1 = require("graphql"); +const types_1 = require("./types"); +const operations_1 = require("./operations"); +const utils_1 = require("./utils"); +function OpenAPI({ schema, info, servers, components, security, tags, customScalars = {}, }) { + const types = schema.getTypeMap(); + const swagger = { + openapi: '3.0.0', + info, + servers, + tags: [], + paths: {}, + components: { + schemas: {}, + }, + }; + for (const typeName in types) { + const type = types[typeName]; + if (((0, graphql_1.isObjectType)(type) || (0, graphql_1.isInputObjectType)(type)) && + !(0, graphql_1.isIntrospectionType)(type)) { + swagger.components.schemas[typeName] = (0, types_1.buildSchemaObjectFromType)(type, { + customScalars, + }); + } + } + if (components) { + swagger.components = { ...components, ...swagger.components }; + } + if (security) { + swagger.security = security; + } + if (tags) { + swagger.tags = tags; + } + return { + addRoute(info, config) { + const basePath = config?.basePath || ''; + const path = basePath + + (0, utils_1.normalizePathParamForOpenAPI)(info.path); + if (!swagger.paths[path]) { + swagger.paths[path] = {}; + } + const pathsObj = swagger.paths[path]; + pathsObj[info.method.toLowerCase()] = (0, operations_1.buildPathFromOperation)({ + url: path, + operation: info.document, + schema, + useRequestBody: ['POST', 'PUT', 'PATCH'].includes(info.method), + tags: info.tags || [], + description: info.description || '', + customScalars, + }); + }, + get() { + return swagger; + }, + }; +} +exports.OpenAPI = OpenAPI; diff --git a/.bob/cjs/src/open-api/operations.d.ts b/.bob/cjs/src/open-api/operations.d.ts new file mode 100644 index 00000000..93bdd7bd --- /dev/null +++ b/.bob/cjs/src/open-api/operations.d.ts @@ -0,0 +1,29 @@ +import { DocumentNode, GraphQLSchema, OperationDefinitionNode, TypeNode, VariableDefinitionNode } from 'graphql'; +import { OpenAPIV3 } from 'openapi-types'; +export declare function buildPathFromOperation({ url, schema, operation, useRequestBody, tags, description, customScalars, }: { + url: string; + schema: GraphQLSchema; + operation: DocumentNode; + useRequestBody: boolean; + tags?: string[]; + description?: string; + customScalars: Record; +}): OpenAPIV3.OperationObject; +export declare function resolveRequestBody(variables: ReadonlyArray | undefined, schema: GraphQLSchema, operation: OperationDefinitionNode, opts: { + customScalars: Record; + enumTypes: Record; +}): {}; +export declare function resolveParamSchema(type: TypeNode, opts: { + customScalars: Record; + enumTypes: Record; +}): any; +export declare function resolveResponse({ schema, operation, opts, }: { + schema: GraphQLSchema; + operation: OperationDefinitionNode; + opts: { + customScalars: Record; + enumTypes: Record; + }; +}): any; +export declare function isInPath(url: string, param: string): boolean; +export declare function resolveVariableDescription(schema: GraphQLSchema, operation: OperationDefinitionNode, variable: VariableDefinitionNode): string | undefined; diff --git a/.bob/cjs/src/open-api/operations.js b/.bob/cjs/src/open-api/operations.js new file mode 100644 index 00000000..615f6653 --- /dev/null +++ b/.bob/cjs/src/open-api/operations.js @@ -0,0 +1,164 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.resolveVariableDescription = exports.isInPath = exports.resolveResponse = exports.resolveParamSchema = exports.resolveRequestBody = exports.buildPathFromOperation = void 0; +const graphql_1 = require("graphql"); +const ast_1 = require("../ast"); +const utils_1 = require("./utils"); +const types_1 = require("./types"); +const title_case_1 = require("title-case"); +function buildPathFromOperation({ url, schema, operation, useRequestBody, tags, description, customScalars, }) { + const info = (0, ast_1.getOperationInfo)(operation); + const enumTypes = resolveEnumTypes(schema); + const summary = resolveDescription(schema, info.operation); + const variables = info.operation.variableDefinitions; + const pathParams = variables?.filter((variable) => isInPath(url, variable.variable.name.value)); + const bodyParams = variables?.filter((variable) => !isInPath(url, variable.variable.name.value)); + return { + tags, + description, + summary, + operationId: info.name, + ...(useRequestBody + ? { + parameters: resolveParameters(url, pathParams, schema, info.operation, { customScalars, enumTypes }), + requestBody: { + content: { + 'application/json': { + schema: resolveRequestBody(bodyParams, schema, info.operation, { customScalars, enumTypes }), + }, + }, + }, + } + : { + parameters: resolveParameters(url, variables, schema, info.operation, { customScalars, enumTypes }), + }), + responses: { + 200: { + description: summary, + content: { + 'application/json': { + schema: resolveResponse({ + schema, + operation: info.operation, + opts: { customScalars, enumTypes }, + }), + }, + }, + }, + }, + }; +} +exports.buildPathFromOperation = buildPathFromOperation; +function resolveEnumTypes(schema) { + const enumTypes = Object.values(schema.getTypeMap()) + .filter(graphql_1.isEnumType); + return Object.fromEntries(enumTypes.map((type) => [ + type.name, + { + type: 'string', + enum: type.getValues().map((value) => value.name), + }, + ])); +} +function resolveParameters(url, variables, schema, operation, opts) { + if (!variables) { + return []; + } + return variables.map((variable) => { + return { + in: isInPath(url, variable.variable.name.value) ? 'path' : 'query', + name: variable.variable.name.value, + required: variable.type.kind === graphql_1.Kind.NON_NULL_TYPE, + schema: resolveParamSchema(variable.type, opts), + description: resolveVariableDescription(schema, operation, variable), + }; + }); +} +function resolveRequestBody(variables, schema, operation, opts) { + if (!variables) { + return {}; + } + const properties = {}; + const required = []; + variables.forEach((variable) => { + if (variable.type.kind === graphql_1.Kind.NON_NULL_TYPE) { + required.push(variable.variable.name.value); + } + properties[variable.variable.name.value] = { + ...resolveParamSchema(variable.type, opts), + description: resolveVariableDescription(schema, operation, variable), + }; + }); + return { + type: 'object', + properties, + ...(required.length ? { required } : {}), + }; +} +exports.resolveRequestBody = resolveRequestBody; +// array -> [type] +// type -> $ref +// scalar -> swagger primitive +function resolveParamSchema(type, opts) { + if (type.kind === graphql_1.Kind.NON_NULL_TYPE) { + return resolveParamSchema(type.type, opts); + } + if (type.kind === graphql_1.Kind.LIST_TYPE) { + return { + type: 'array', + items: resolveParamSchema(type.type, opts), + }; + } + const primitive = (0, utils_1.mapToPrimitive)(type.name.value); + return (primitive || + opts.customScalars[type.name.value] || + opts.enumTypes[type.name.value] || { $ref: (0, utils_1.mapToRef)(type.name.value) }); +} +exports.resolveParamSchema = resolveParamSchema; +function resolveResponse({ schema, operation, opts, }) { + const operationType = operation.operation; + const rootField = operation.selectionSet.selections[0]; + if (rootField.kind === graphql_1.Kind.FIELD) { + if (operationType === 'query') { + const queryType = schema.getQueryType(); + const field = queryType.getFields()[rootField.name.value]; + return (0, types_1.resolveFieldType)(field.type, opts); + } + if (operationType === 'mutation') { + const mutationType = schema.getMutationType(); + const field = mutationType.getFields()[rootField.name.value]; + return (0, types_1.resolveFieldType)(field.type, opts); + } + } +} +exports.resolveResponse = resolveResponse; +function isInPath(url, param) { + return url.includes(`:${param}`) || url.includes(`{${param}}`); +} +exports.isInPath = isInPath; +function getOperationFieldNode(schema, operation) { + const selection = operation.selectionSet.selections[0]; + const fieldName = selection.name.value; + const typeDefinition = schema.getType((0, title_case_1.titleCase)(operation.operation)); + if (!typeDefinition) { + return undefined; + } + const definitionNode = typeDefinition.astNode || (0, graphql_1.parse)((0, graphql_1.printType)(typeDefinition)).definitions[0]; + if (!isObjectTypeDefinitionNode(definitionNode)) { + return undefined; + } + return definitionNode.fields.find((field) => field.name.value === fieldName); +} +function resolveDescription(schema, operation) { + const fieldNode = getOperationFieldNode(schema, operation); + return fieldNode?.description?.value || ''; +} +function resolveVariableDescription(schema, operation, variable) { + const fieldNode = getOperationFieldNode(schema, operation); + const argument = fieldNode?.arguments?.find((arg) => arg.name.value === variable.variable.name.value); + return argument?.description?.value; +} +exports.resolveVariableDescription = resolveVariableDescription; +function isObjectTypeDefinitionNode(node) { + return node.kind === graphql_1.Kind.OBJECT_TYPE_DEFINITION; +} diff --git a/.bob/cjs/src/open-api/types.d.ts b/.bob/cjs/src/open-api/types.d.ts new file mode 100644 index 00000000..43d3f369 --- /dev/null +++ b/.bob/cjs/src/open-api/types.d.ts @@ -0,0 +1,7 @@ +import { GraphQLObjectType, GraphQLInputObjectType, GraphQLType } from 'graphql'; +export declare function buildSchemaObjectFromType(type: GraphQLObjectType | GraphQLInputObjectType, opts: { + customScalars: Record; +}): any; +export declare function resolveFieldType(type: GraphQLType, opts: { + customScalars: Record; +}): any; diff --git a/.bob/cjs/src/open-api/types.js b/.bob/cjs/src/open-api/types.js new file mode 100644 index 00000000..589210df --- /dev/null +++ b/.bob/cjs/src/open-api/types.js @@ -0,0 +1,67 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.resolveFieldType = exports.buildSchemaObjectFromType = void 0; +const graphql_1 = require("graphql"); +const utils_1 = require("./utils"); +function buildSchemaObjectFromType(type, opts) { + const required = []; + const properties = {}; + const fields = type.getFields(); + for (const fieldName in fields) { + const field = fields[fieldName]; + if ((0, graphql_1.isNonNullType)(field.type)) { + required.push(field.name); + } + properties[fieldName] = resolveField(field, opts); + if (field.description) { + properties[fieldName].description = field.description; + } + } + return { + type: 'object', + ...(required.length ? { required } : {}), + properties, + ...(type.description ? { description: type.description } : {}), + }; +} +exports.buildSchemaObjectFromType = buildSchemaObjectFromType; +function resolveField(field, opts) { + return resolveFieldType(field.type, opts); +} +// array -> [type] +// type -> $ref +// scalar -> swagger primitive +function resolveFieldType(type, opts) { + if ((0, graphql_1.isNonNullType)(type)) { + return resolveFieldType(type.ofType, opts); + } + if ((0, graphql_1.isListType)(type)) { + return { + type: 'array', + items: resolveFieldType(type.ofType, opts), + }; + } + if ((0, graphql_1.isObjectType)(type)) { + return { + $ref: (0, utils_1.mapToRef)(type.name), + }; + } + if ((0, graphql_1.isScalarType)(type)) { + const resolved = (0, utils_1.mapToPrimitive)(type.name) || + opts.customScalars[type.name] || + type.extensions?.jsonSchema || { + type: 'object', + }; + return { ...resolved }; + } + if ((0, graphql_1.isEnumType)(type)) { + return { + type: 'string', + enum: type.getValues().map((value) => value.name), + }; + } + return { + type: 'object', + }; +} +exports.resolveFieldType = resolveFieldType; diff --git a/.bob/cjs/src/open-api/utils.d.ts b/.bob/cjs/src/open-api/utils.d.ts new file mode 100644 index 00000000..ccfed6dd --- /dev/null +++ b/.bob/cjs/src/open-api/utils.d.ts @@ -0,0 +1,3 @@ +export declare function mapToPrimitive(type: string): any; +export declare function mapToRef(type: string): string; +export declare function normalizePathParamForOpenAPI(path: string): string; diff --git a/.bob/cjs/src/open-api/utils.js b/.bob/cjs/src/open-api/utils.js new file mode 100644 index 00000000..1e26f01c --- /dev/null +++ b/.bob/cjs/src/open-api/utils.js @@ -0,0 +1,43 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.normalizePathParamForOpenAPI = exports.mapToRef = exports.mapToPrimitive = void 0; +function mapToPrimitive(type) { + const formatMap = { + Int: { + type: 'integer', + format: 'int32', + }, + Float: { + type: 'number', + format: 'float', + }, + String: { + type: 'string', + }, + Boolean: { + type: 'boolean', + }, + ID: { + type: 'string', + }, + }; + if (formatMap[type]) { + return formatMap[type]; + } +} +exports.mapToPrimitive = mapToPrimitive; +function mapToRef(type) { + return `#/components/schemas/${type}`; +} +exports.mapToRef = mapToRef; +function normalizePathParamForOpenAPI(path) { + const pathParts = path.split('/'); + const normalizedPathParts = pathParts.map((part) => { + if (part.startsWith(':')) { + return `{${part.slice(1)}}`; + } + return part; + }); + return normalizedPathParts.join('/'); +} +exports.normalizePathParamForOpenAPI = normalizePathParamForOpenAPI; diff --git a/.bob/cjs/src/parse.d.ts b/.bob/cjs/src/parse.d.ts new file mode 100644 index 00000000..e22c81a7 --- /dev/null +++ b/.bob/cjs/src/parse.d.ts @@ -0,0 +1,6 @@ +import { type VariableDefinitionNode, type GraphQLSchema } from 'graphql'; +export declare function parseVariable({ value, variable, schema, }: { + value: any; + variable: VariableDefinitionNode; + schema: GraphQLSchema; +}): any; diff --git a/.bob/cjs/src/parse.js b/.bob/cjs/src/parse.js new file mode 100644 index 00000000..01d2af2f --- /dev/null +++ b/.bob/cjs/src/parse.js @@ -0,0 +1,46 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.parseVariable = void 0; +const graphql_1 = require("graphql"); +const common_js_1 = require("./common.js"); +function parseVariable({ value, variable, schema, }) { + if ((0, common_js_1.isNil)(value)) { + return; + } + return resolveVariable({ + value, + type: variable.type, + schema, + }); +} +exports.parseVariable = parseVariable; +function resolveVariable({ value, type, schema, }) { + if (type.kind === graphql_1.Kind.NAMED_TYPE) { + const namedType = schema.getType(type.name.value); + if ((0, graphql_1.isScalarType)(namedType)) { + // GraphQLBoolean.serialize expects a boolean or a number only + if ((0, graphql_1.isEqualType)(graphql_1.GraphQLBoolean, namedType)) { + value = (value === 'true' || value === true); + } + return namedType.serialize(value); + } + if ((0, graphql_1.isInputObjectType)(namedType)) { + return value && typeof value === 'object' ? value : JSON.parse(value); + } + return value; + } + if (type.kind === graphql_1.Kind.LIST_TYPE) { + return (Array.isArray(value) ? value : [value]).map((val) => resolveVariable({ + value: val, + type: type.type, + schema, + })); + } + if (type.kind === graphql_1.Kind.NON_NULL_TYPE) { + return resolveVariable({ + value: value, + type: type.type, + schema, + }); + } +} diff --git a/.bob/cjs/src/router.d.ts b/.bob/cjs/src/router.d.ts new file mode 100644 index 00000000..1df90ade --- /dev/null +++ b/.bob/cjs/src/router.d.ts @@ -0,0 +1,16 @@ +import type { Sofa } from './sofa.js'; +import { Response, type Router } from 'fets'; +export type ErrorHandler = (errors: ReadonlyArray) => Response; +declare module 'graphql' { + interface GraphQLHTTPErrorExtensions { + spec?: boolean; + status?: number; + headers?: Record; + } + interface GraphQLErrorExtensions { + http?: GraphQLHTTPErrorExtensions; + } +} +export declare function createRouter(sofa: Sofa): Router; diff --git a/.bob/cjs/src/router.js b/.bob/cjs/src/router.js new file mode 100644 index 00000000..3eeb693a --- /dev/null +++ b/.bob/cjs/src/router.js @@ -0,0 +1,386 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createRouter = void 0; +const graphql_1 = require("graphql"); +const utils_1 = require("@graphql-tools/utils"); +const ast_js_1 = require("./ast.js"); +const common_1 = require("./common"); +const parse_js_1 = require("./parse.js"); +const subscriptions_js_1 = require("./subscriptions.js"); +const logger_js_1 = require("./logger.js"); +const fets_1 = require("fets"); +const operations_js_1 = require("./open-api/operations.js"); +const types_js_1 = require("./open-api/types.js"); +const defaultErrorHandler = (errors) => { + let status; + const headers = { + 'Content-Type': 'application/json; charset=utf-8', + }; + for (const error of errors) { + if (typeof error === 'object' && + error != null && + error.extensions?.http) { + if (error.extensions.http.status && + (!status || error.extensions.http.status > status)) { + status = error.extensions.http.status; + } + if (error.extensions.http.headers) { + Object.assign(headers, error.extensions.http.headers); + } + delete error.extensions.http; + } + } + if (!status) { + status = 500; + } + return fets_1.Response.json({ errors }, { + status, + headers, + }); +}; +function useRequestBody(method) { + return method === 'POST' || method === 'PUT' || method === 'PATCH'; +} +function createRouter(sofa) { + logger_js_1.logger.debug('[Sofa] Creating router'); + sofa.openAPI ||= {}; + sofa.openAPI.info ||= {}; + sofa.openAPI.info.title ||= 'SOFA API'; + sofa.openAPI.info.description ||= 'Generated by SOFA'; + sofa.openAPI.info.version ||= '0.0.0'; + sofa.openAPI.components ||= {}; + sofa.openAPI.components.schemas ||= {}; + const types = sofa.schema.getTypeMap(); + for (const typeName in types) { + const type = types[typeName]; + if (((0, graphql_1.isObjectType)(type) || (0, graphql_1.isInputObjectType)(type)) && + !(0, graphql_1.isIntrospectionType)(type)) { + sofa.openAPI.components.schemas[typeName] = (0, types_js_1.buildSchemaObjectFromType)(type, { + customScalars: sofa.customScalars, + }); + } + } + const router = (0, fets_1.createRouter)({ + base: sofa.basePath, + openAPI: sofa.openAPI, + swaggerUI: sofa.swaggerUI, + landingPage: false, + }); + const queryType = sofa.schema.getQueryType(); + const mutationType = sofa.schema.getMutationType(); + const subscriptionManager = new subscriptions_js_1.SubscriptionManager(sofa); + if (queryType) { + Object.keys(queryType.getFields()).forEach((fieldName) => { + createQueryRoute({ sofa, router, fieldName }); + }); + } + if (mutationType) { + Object.keys(mutationType.getFields()).forEach((fieldName) => { + createMutationRoute({ sofa, router, fieldName }); + }); + } + router.route({ + path: '/webhook', + method: 'POST', + async handler(request, serverContext) { + const { subscription, variables, url } = await request.json(); + try { + const sofaContext = Object.assign(serverContext, { + request, + }); + const result = await subscriptionManager.start({ + subscription, + variables, + url, + }, sofaContext); + return fets_1.Response.json(result); + } + catch (error) { + return fets_1.Response.json(error, { + status: 500, + statusText: 'Subscription failed', + }); + } + } + }); + router.route({ + path: '/webhook/:id', + method: 'POST', + async handler(request, serverContext) { + const id = request.params?.id; + const body = await request.json(); + const variables = body.variables; + try { + const sofaContext = Object.assign(serverContext, { + request, + }); + const contextValue = await sofa.contextFactory(sofaContext); + const result = await subscriptionManager.update({ + id, + variables, + }, contextValue); + return fets_1.Response.json(result); + } + catch (error) { + return fets_1.Response.json(error, { + status: 500, + statusText: 'Subscription failed to update', + }); + } + } + }); + router.route({ + path: '/webhook/:id', + method: 'DELETE', + async handler(request) { + const id = request.params?.id; + try { + const result = await subscriptionManager.stop(id); + return fets_1.Response.json(result); + } + catch (error) { + return fets_1.Response.json(error, { + status: 500, + statusText: 'Subscription failed to stop', + }); + } + } + }); + return router; +} +exports.createRouter = createRouter; +function createQueryRoute({ sofa, router, fieldName, }) { + logger_js_1.logger.debug(`[Router] Creating ${fieldName} query`); + const queryType = sofa.schema.getQueryType(); + const operationNode = (0, utils_1.buildOperationNodeForField)({ + kind: 'query', + schema: sofa.schema, + field: fieldName, + models: sofa.models, + ignore: sofa.ignore, + circularReferenceDepth: sofa.depthLimit, + }); + const operation = { + kind: graphql_1.Kind.DOCUMENT, + definitions: [operationNode], + }; + const info = (0, ast_js_1.getOperationInfo)(operation); + const field = queryType.getFields()[fieldName]; + const fieldType = field.type; + const isSingle = (0, graphql_1.isObjectType)(fieldType) || + ((0, graphql_1.isNonNullType)(fieldType) && (0, graphql_1.isObjectType)(fieldType.ofType)); + const hasIdArgument = field.args.some((arg) => arg.name === 'id'); + const graphqlPath = `${queryType.name}.${fieldName}`; + const routeConfig = sofa.routes?.[graphqlPath]; + const route = { + method: routeConfig?.method ?? 'GET', + path: routeConfig?.path ?? getPath(fieldName, isSingle && hasIdArgument), + responseStatus: routeConfig?.responseStatus ?? 200, + }; + router.route({ + path: route.path, + method: route.method, + schemas: getRouteSchemas({ + method: route.method, + path: route.path, + info, + sofa, + responseStatus: route.responseStatus, + }), + handler: useHandler({ info, route, fieldName, sofa, operation }), + }); + logger_js_1.logger.debug(`[Router] ${fieldName} query available at ${route.method} ${route.path}`); + return { + document: operation, + path: route.path, + method: route.method.toUpperCase(), + tags: routeConfig?.tags ?? [], + description: routeConfig?.description ?? field.description ?? '', + }; +} +function getRouteSchemas({ method, path, info, sofa, responseStatus, }) { + const params = { + properties: {}, + required: [], + }; + const query = { + properties: {}, + required: [], + }; + for (const variable of info.variables) { + const varSchema = (0, operations_js_1.resolveParamSchema)(variable.type, { + customScalars: sofa.customScalars, + enumTypes: sofa.enumTypes, + }); + varSchema.description = (0, operations_js_1.resolveVariableDescription)(sofa.schema, info.operation, variable); + const varName = variable.variable.name.value; + const varObj = (0, operations_js_1.isInPath)(path, varName) ? params : query; + varObj.properties[varName] = varSchema; + if (variable.type.kind === graphql_1.Kind.NON_NULL_TYPE) { + varObj.required.push(varName); + } + } + return { + request: { + json: useRequestBody(method) ? (0, operations_js_1.resolveRequestBody)(info.variables, sofa.schema, info.operation, { + customScalars: sofa.customScalars, + enumTypes: sofa.enumTypes, + }) : undefined, + params, + query, + }, + responses: { + [responseStatus]: (0, operations_js_1.resolveResponse)({ + schema: sofa.schema, + operation: info.operation, + opts: { + customScalars: sofa.customScalars, + enumTypes: sofa.enumTypes, + } + }) + } + }; +} +function createMutationRoute({ sofa, router, fieldName, }) { + logger_js_1.logger.debug(`[Router] Creating ${fieldName} mutation`); + const mutationType = sofa.schema.getMutationType(); + const field = mutationType.getFields()[fieldName]; + const operationNode = (0, utils_1.buildOperationNodeForField)({ + kind: 'mutation', + schema: sofa.schema, + field: fieldName, + models: sofa.models, + ignore: sofa.ignore, + circularReferenceDepth: sofa.depthLimit, + }); + const operation = { + kind: graphql_1.Kind.DOCUMENT, + definitions: [operationNode], + }; + const info = (0, ast_js_1.getOperationInfo)(operation); + const graphqlPath = `${mutationType.name}.${fieldName}`; + const routeConfig = sofa.routes?.[graphqlPath]; + const method = routeConfig?.method ?? 'POST'; + const path = routeConfig?.path ?? getPath(fieldName); + const responseStatus = routeConfig?.responseStatus ?? 200; + const route = { + method, + path, + responseStatus, + }; + router.route({ + method, + path, + schemas: getRouteSchemas({ + method, + path, + info, + responseStatus, + sofa, + }), + handler: useHandler({ info, route, fieldName, sofa, operation }), + }); + logger_js_1.logger.debug(`[Router] ${fieldName} mutation available at ${method} ${path}`); + return { + document: operation, + path, + method, + tags: routeConfig?.tags || [], + description: routeConfig?.description ?? field.description ?? '', + }; +} +function useHandler(config) { + const { sofa, operation, fieldName } = config; + const info = config.info; + const errorHandler = sofa.errorHandler || defaultErrorHandler; + return async (request, serverContext) => { + try { + let body = {}; + if (request.body != null) { + const strBody = await request.text(); + if (strBody) { + try { + body = JSON.parse(strBody); + } + catch (error) { + throw (0, utils_1.createGraphQLError)('POST body sent invalid JSON.', { + extensions: { + http: { + status: 400, + } + } + }); + } + } + } + let variableValues = {}; + try { + variableValues = info.variables.reduce((variables, variable) => { + const name = variable.variable.name.value; + const value = (0, parse_js_1.parseVariable)({ + value: pickParam({ + url: request.url, + body, + params: request.params || {}, + name, + }), + variable, + schema: sofa.schema, + }); + if (typeof value === 'undefined') { + return variables; + } + return { + ...variables, + [name]: value, + }; + }, {}); + } + catch (error) { + throw (0, utils_1.createGraphQLError)(error.message || error.toString?.() || error, { + extensions: { + http: { + status: 400, + } + } + }); + } + const sofaContext = Object.assign(serverContext, { + request, + }); + const contextValue = await sofa.contextFactory(sofaContext); + const result = await sofa.execute({ + schema: sofa.schema, + document: operation, + contextValue, + variableValues, + operationName: info.operation.name && info.operation.name.value, + }); + if (result.errors) { + return errorHandler(result.errors); + } + return fets_1.Response.json(result.data?.[fieldName], { + status: config.route.responseStatus, + }); + } + catch (error) { + return errorHandler([error]); + } + }; +} +function getPath(fieldName, hasId = false) { + return `/${(0, common_1.convertName)(fieldName)}${hasId ? '/:id' : ''}`; +} +function pickParam({ name, url, params, body, }) { + if (name in params) { + return params[name]; + } + const searchParams = new URLSearchParams(url.split('?')[1]); + if (searchParams.has(name)) { + const values = searchParams.getAll(name); + return values.length === 1 ? values[0] : values; + } + if (body && body.hasOwnProperty(name)) { + return body[name]; + } +} diff --git a/.bob/cjs/src/sofa.d.ts b/.bob/cjs/src/sofa.d.ts new file mode 100644 index 00000000..3be9ec37 --- /dev/null +++ b/.bob/cjs/src/sofa.d.ts @@ -0,0 +1,57 @@ +import { GraphQLSchema, subscribe, execute } from 'graphql'; +import type { Ignore, ContextFn, ContextValue } from './types.js'; +import type { ErrorHandler } from './router.js'; +import type { HTTPMethod, StatusCode } from 'fets/typings/typed-fetch'; +import type { RouterOpenAPIOptions, RouterSwaggerUIOptions } from 'fets'; +export interface RouteConfig { + method?: HTTPMethod; + path?: string; + responseStatus?: StatusCode; + tags?: string[]; + description?: string; +} +export interface Route { + method: HTTPMethod; + path: string; + responseStatus: StatusCode; +} +export interface SofaConfig { + basePath: string; + schema: GraphQLSchema; + execute?: typeof execute; + subscribe?: typeof subscribe; + /** + * Treats an Object with an ID as not a model. + * @example ["User", "Message.author"] + */ + ignore?: Ignore; + depthLimit?: number; + errorHandler?: ErrorHandler; + /** + * Overwrites the default HTTP route. + */ + routes?: Record; + context?: ContextFn | ContextValue; + customScalars?: Record; + enumTypes?: Record; + openAPI?: RouterOpenAPIOptions; + swaggerUI?: RouterSwaggerUIOptions; +} +export interface Sofa { + basePath: string; + schema: GraphQLSchema; + models: string[]; + ignore: Ignore; + depthLimit: number; + routes?: Record; + execute: typeof execute; + subscribe: typeof subscribe; + errorHandler?: ErrorHandler; + contextFactory: ContextFn; + customScalars: Record; + enumTypes: Record; + openAPI?: RouterOpenAPIOptions; + swaggerUI?: RouterSwaggerUIOptions; +} +export declare function createSofa(config: SofaConfig): Sofa; +export declare function isContextFn(context: any): context is ContextFn; diff --git a/.bob/cjs/src/sofa.js b/.bob/cjs/src/sofa.js new file mode 100644 index 00000000..db1c8ff9 --- /dev/null +++ b/.bob/cjs/src/sofa.js @@ -0,0 +1,92 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.isContextFn = exports.createSofa = void 0; +const graphql_1 = require("graphql"); +const common_js_1 = require("./common.js"); +const logger_js_1 = require("./logger.js"); +function createSofa(config) { + logger_js_1.logger.debug('[Sofa] Created'); + const models = extractsModels(config.schema); + const ignore = config.ignore || []; + const depthLimit = config.depthLimit || 1; + logger_js_1.logger.debug(`[Sofa] models: ${models.join(', ')}`); + logger_js_1.logger.debug(`[Sofa] ignore: ${ignore.join(', ')}`); + return { + execute: graphql_1.execute, + subscribe: graphql_1.subscribe, + models, + ignore, + depthLimit, + contextFactory(serverContext) { + if (config.context != null) { + if (isContextFn(config.context)) { + return config.context(serverContext); + } + else { + return config.context; + } + } + return serverContext; + }, + customScalars: config.customScalars || {}, + enumTypes: config.enumTypes || {}, + ...config, + }; +} +exports.createSofa = createSofa; +function isContextFn(context) { + return typeof context === 'function'; +} +exports.isContextFn = isContextFn; +// Objects and Unions are the only things that are used to define return types +// and both might contain an ID +// We don't treat Unions as models because +// they might represent an Object that is not a model +// We check it later, when an operation is being built +function extractsModels(schema) { + const modelMap = {}; + const query = schema.getQueryType(); + const fields = query.getFields(); + // if Query[type] (no args) and Query[type](just id as an argument) + // loop through every field + for (const fieldName in fields) { + const field = fields[fieldName]; + const namedType = (0, graphql_1.getNamedType)(field.type); + if (hasID(namedType)) { + if (!modelMap[namedType.name]) { + modelMap[namedType.name] = {}; + } + if (isArrayOf(field.type, namedType)) { + // check if type is a list + // check if name of a field matches a name of a named type (in plural) + // check if has no non-optional arguments + // add to registry with `list: true` + const sameName = isNameEqual(field.name, namedType.name + 's'); + const allOptionalArguments = !field.args.some((arg) => (0, graphql_1.isNonNullType)(arg.type)); + modelMap[namedType.name].list ||= sameName && allOptionalArguments; + } + else if ((0, graphql_1.isObjectType)(field.type) || + ((0, graphql_1.isNonNullType)(field.type) && (0, graphql_1.isObjectType)(field.type.ofType))) { + // check if type is a graphql object type + // check if name of a field matches with name of an object type + // check if has only one argument named `id` + // add to registry with `single: true` + const sameName = isNameEqual(field.name, namedType.name); + const hasIdArgument = field.args.length === 1 && field.args[0].name === 'id'; + modelMap[namedType.name].single ||= sameName && hasIdArgument; + } + } + } + return Object.keys(modelMap).filter((name) => modelMap[name].list && modelMap[name].single); +} +// it's dumb but let's leave it for now +function isArrayOf(type, expected) { + const typeNameInSdl = type.toString(); + return (typeNameInSdl.includes('[') && typeNameInSdl.includes(expected.toString())); +} +function hasID(type) { + return (0, graphql_1.isObjectType)(type) && !!type.getFields().id; +} +function isNameEqual(a, b) { + return (0, common_js_1.convertName)(a) === (0, common_js_1.convertName)(b); +} diff --git a/.bob/cjs/src/subscriptions.d.ts b/.bob/cjs/src/subscriptions.d.ts new file mode 100644 index 00000000..6c594b09 --- /dev/null +++ b/.bob/cjs/src/subscriptions.d.ts @@ -0,0 +1,33 @@ +import { type ExecutionResult } from 'graphql'; +import type { ContextValue } from './types.js'; +import type { Sofa } from './sofa.js'; +export type ID = string; +export type SubscriptionFieldName = string; +export interface StartSubscriptionEvent { + subscription: SubscriptionFieldName; + variables: any; + url: string; +} +export interface UpdateSubscriptionEvent { + id: ID; + variables: any; +} +export interface StopSubscriptionResponse { + id: ID; +} +export declare class SubscriptionManager { + private sofa; + private operations; + private clients; + constructor(sofa: Sofa); + start(event: StartSubscriptionEvent, contextValue: ContextValue): Promise, import("graphql/jsutils/ObjMap.js").ObjMap> | { + id: `${string}-${string}-${string}-${string}-${string}`; + }>; + stop(id: ID): Promise; + update(event: UpdateSubscriptionEvent, contextValue: ContextValue): Promise, import("graphql/jsutils/ObjMap.js").ObjMap> | { + id: `${string}-${string}-${string}-${string}-${string}`; + }>; + private execute; + private sendData; + private buildOperations; +} diff --git a/.bob/cjs/src/subscriptions.js b/.bob/cjs/src/subscriptions.js new file mode 100644 index 00000000..cff25ecc --- /dev/null +++ b/.bob/cjs/src/subscriptions.js @@ -0,0 +1,160 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SubscriptionManager = void 0; +const graphql_1 = require("graphql"); +const fetch_1 = require("@whatwg-node/fetch"); +const utils_1 = require("@graphql-tools/utils"); +const ast_js_1 = require("./ast.js"); +const parse_js_1 = require("./parse.js"); +const logger_js_1 = require("./logger.js"); +function isAsyncIterable(obj) { + return typeof obj[Symbol.asyncIterator] === 'function'; +} +class SubscriptionManager { + constructor(sofa) { + this.sofa = sofa; + this.operations = new Map(); + this.clients = new Map(); + this.buildOperations(); + } + async start(event, contextValue) { + const id = fetch_1.crypto.randomUUID(); + const name = event.subscription; + if (!this.operations.has(name)) { + throw new Error(`Subscription '${name}' is not available`); + } + logger_js_1.logger.info(`[Subscription] Start ${id}`, event); + const result = await this.execute({ + id, + name, + url: event.url, + variables: event.variables, + contextValue, + }); + if (typeof result !== 'undefined') { + return result; + } + return { id }; + } + async stop(id) { + logger_js_1.logger.info(`[Subscription] Stop ${id}`); + if (!this.clients.has(id)) { + throw new Error(`Subscription with ID '${id}' does not exist`); + } + const execution = this.clients.get(id); + if (execution.iterator.return) { + execution.iterator.return(); + } + this.clients.delete(id); + return { id }; + } + async update(event, contextValue) { + const { variables, id } = event; + logger_js_1.logger.info(`[Subscription] Update ${id}`, event); + if (!this.clients.has(id)) { + throw new Error(`Subscription with ID '${id}' does not exist`); + } + const { name: subscription, url } = this.clients.get(id); + this.stop(id); + return this.start({ + url, + subscription, + variables, + }, contextValue); + } + async execute({ id, name, url, variables, contextValue, }) { + const { document, operationName, variables: variableNodes } = this.operations.get(name); + const variableValues = variableNodes.reduce((values, variable) => { + const value = (0, parse_js_1.parseVariable)({ + value: variables[variable.variable.name.value], + variable, + schema: this.sofa.schema, + }); + if (typeof value === 'undefined') { + return values; + } + return { + ...values, + [variable.variable.name.value]: value, + }; + }, {}); + const execution = await this.sofa.subscribe({ + schema: this.sofa.schema, + document, + operationName, + variableValues, + contextValue, + }); + if (isAsyncIterable(execution)) { + // successful + // add execution to clients + this.clients.set(id, { + name, + url, + iterator: execution, + }); + // success + (async () => { + for await (const result of execution) { + await this.sendData({ + id, + result, + }); + } + })().then(() => { + // completes + this.clients.delete(id); + }, (e) => { + logger_js_1.logger.info(`Subscription #${id} closed`); + logger_js_1.logger.error(e); + this.clients.delete(id); + }); + } + else { + return execution; + } + } + async sendData({ id, result }) { + if (!this.clients.has(id)) { + throw new Error(`Subscription with ID '${id}' does not exist`); + } + const { url } = this.clients.get(id); + logger_js_1.logger.info(`[Subscription] Trigger ${id}`); + const response = await (0, fetch_1.fetch)(url, { + method: 'POST', + body: JSON.stringify(result), + headers: { + 'Content-Type': 'application/json', + }, + }); + await response.text(); + } + buildOperations() { + const subscription = this.sofa.schema.getSubscriptionType(); + if (!subscription) { + return; + } + const fieldMap = subscription.getFields(); + for (const field in fieldMap) { + const operationNode = (0, utils_1.buildOperationNodeForField)({ + kind: 'subscription', + field, + schema: this.sofa.schema, + models: this.sofa.models, + ignore: this.sofa.ignore, + circularReferenceDepth: this.sofa.depthLimit, + }); + const document = { + kind: graphql_1.Kind.DOCUMENT, + definitions: [operationNode], + }; + const { variables, name: operationName } = (0, ast_js_1.getOperationInfo)(document); + this.operations.set(field, { + operationName, + document, + variables, + }); + } + } +} +exports.SubscriptionManager = SubscriptionManager; diff --git a/.bob/cjs/src/types.d.ts b/.bob/cjs/src/types.d.ts new file mode 100644 index 00000000..ac8410a2 --- /dev/null +++ b/.bob/cjs/src/types.d.ts @@ -0,0 +1,15 @@ +import type { HTTPMethod } from 'fets/typings/typed-fetch'; +import type { DocumentNode } from 'graphql'; +export type ContextValue = Record; +export type Ignore = string[]; +export interface RouteInfo { + document: DocumentNode; + path: string; + method: HTTPMethod; + tags?: string[]; + description?: string; +} +export type ContextFn = (serverContext: DefaultSofaServerContext) => Promise | ContextValue; +export type DefaultSofaServerContext = { + request: Request; +}; diff --git a/.bob/cjs/src/types.js b/.bob/cjs/src/types.js new file mode 100644 index 00000000..e69de29b diff --git a/.bob/esm/src/ast.d.ts b/.bob/esm/src/ast.d.ts new file mode 100644 index 00000000..093c84af --- /dev/null +++ b/.bob/esm/src/ast.d.ts @@ -0,0 +1,7 @@ +import { type DocumentNode, type OperationDefinitionNode, type VariableDefinitionNode } from 'graphql'; +export type OperationInfo = { + operation: OperationDefinitionNode; + variables: ReadonlyArray; + name: string; +} | undefined; +export declare function getOperationInfo(doc: DocumentNode): OperationInfo; diff --git a/.bob/esm/src/ast.js b/.bob/esm/src/ast.js new file mode 100644 index 00000000..f8c7ee99 --- /dev/null +++ b/.bob/esm/src/ast.js @@ -0,0 +1,12 @@ +import { getOperationAST, } from 'graphql'; +export function getOperationInfo(doc) { + const op = getOperationAST(doc, null); + if (!op) { + return; + } + return { + operation: op, + name: op.name.value, + variables: op.variableDefinitions || [], + }; +} diff --git a/.bob/esm/src/common.d.ts b/.bob/esm/src/common.d.ts new file mode 100644 index 00000000..910ba4e8 --- /dev/null +++ b/.bob/esm/src/common.d.ts @@ -0,0 +1,2 @@ +export declare function convertName(name: string): string; +export declare function isNil(val: T): boolean; diff --git a/.bob/esm/src/common.js b/.bob/esm/src/common.js new file mode 100644 index 00000000..38f6ebe4 --- /dev/null +++ b/.bob/esm/src/common.js @@ -0,0 +1,7 @@ +import { paramCase } from 'param-case'; +export function convertName(name) { + return paramCase(name); +} +export function isNil(val) { + return val == null; +} diff --git a/.bob/esm/src/index.d.ts b/.bob/esm/src/index.d.ts new file mode 100644 index 00000000..b23585b4 --- /dev/null +++ b/.bob/esm/src/index.d.ts @@ -0,0 +1,5 @@ +import type { SofaConfig } from './sofa'; +export { OpenAPI } from './open-api'; +export declare function useSofa(config: SofaConfig): import("fets").Router; diff --git a/.bob/esm/src/index.js b/.bob/esm/src/index.js new file mode 100644 index 00000000..d7c51c28 --- /dev/null +++ b/.bob/esm/src/index.js @@ -0,0 +1,6 @@ +import { createRouter } from './router'; +import { createSofa } from './sofa'; +export { OpenAPI } from './open-api'; +export function useSofa(config) { + return createRouter(createSofa(config)); +} diff --git a/.bob/esm/src/logger.d.ts b/.bob/esm/src/logger.d.ts new file mode 100644 index 00000000..28edbb6b --- /dev/null +++ b/.bob/esm/src/logger.d.ts @@ -0,0 +1,6 @@ +export declare const logger: { + error: (...args: any[]) => void; + warn: (...args: any[]) => void; + info: (...args: any[]) => void; + debug: (...args: any[]) => void; +}; diff --git a/.bob/esm/src/logger.js b/.bob/esm/src/logger.js new file mode 100644 index 00000000..1e5b5208 --- /dev/null +++ b/.bob/esm/src/logger.js @@ -0,0 +1,25 @@ +import colors from 'ansi-colors'; +const levels = ['error', 'warn', 'info', 'debug']; +const toLevel = (string) => levels.includes(string) ? string : null; +const currentLevel = globalThis.process?.env?.SOFA_DEBUG + ? 'debug' + : toLevel(globalThis.process?.env?.SOFA_LOGGER_LEVEL) ?? 'info'; +const log = (level, color, args) => { + if (levels.indexOf(level) <= levels.indexOf(currentLevel)) { + console.log(`${color(level)}:`, ...args); + } +}; +export const logger = { + error: (...args) => { + log('error', colors.red, args); + }, + warn: (...args) => { + log('warn', colors.yellow, args); + }, + info: (...args) => { + log('info', colors.green, args); + }, + debug: (...args) => { + log('debug', colors.blue, args); + }, +}; diff --git a/.bob/esm/src/open-api/index.d.ts b/.bob/esm/src/open-api/index.d.ts new file mode 100644 index 00000000..721f772c --- /dev/null +++ b/.bob/esm/src/open-api/index.d.ts @@ -0,0 +1,26 @@ +import { GraphQLSchema } from 'graphql'; +import { RouteInfo } from '../types'; +import { OpenAPIV3 } from 'openapi-types'; +export declare function OpenAPI({ schema, info, servers, components, security, tags, customScalars, }: { + schema: GraphQLSchema; + info: OpenAPIV3.InfoObject; + servers?: OpenAPIV3.ServerObject[]; + components?: Record; + security?: OpenAPIV3.SecurityRequirementObject[]; + tags?: OpenAPIV3.TagObject[]; + /** + * Override mapping of custom scalars to OpenAPI + * @example + * ```js + * { + * Date: { type: "string", format: "date" } + * } + * ``` + */ + customScalars?: Record; +}): { + addRoute(info: RouteInfo, config?: { + basePath?: string; + }): void; + get(): OpenAPIV3.Document<{}>; +}; diff --git a/.bob/esm/src/open-api/index.js b/.bob/esm/src/open-api/index.js new file mode 100644 index 00000000..5f57a15f --- /dev/null +++ b/.bob/esm/src/open-api/index.js @@ -0,0 +1,58 @@ +import { isObjectType, isInputObjectType, isIntrospectionType, } from 'graphql'; +import { buildSchemaObjectFromType } from './types'; +import { buildPathFromOperation } from './operations'; +import { normalizePathParamForOpenAPI } from './utils'; +export function OpenAPI({ schema, info, servers, components, security, tags, customScalars = {}, }) { + const types = schema.getTypeMap(); + const swagger = { + openapi: '3.0.0', + info, + servers, + tags: [], + paths: {}, + components: { + schemas: {}, + }, + }; + for (const typeName in types) { + const type = types[typeName]; + if ((isObjectType(type) || isInputObjectType(type)) && + !isIntrospectionType(type)) { + swagger.components.schemas[typeName] = buildSchemaObjectFromType(type, { + customScalars, + }); + } + } + if (components) { + swagger.components = { ...components, ...swagger.components }; + } + if (security) { + swagger.security = security; + } + if (tags) { + swagger.tags = tags; + } + return { + addRoute(info, config) { + const basePath = config?.basePath || ''; + const path = basePath + + normalizePathParamForOpenAPI(info.path); + if (!swagger.paths[path]) { + swagger.paths[path] = {}; + } + const pathsObj = swagger.paths[path]; + pathsObj[info.method.toLowerCase()] = buildPathFromOperation({ + url: path, + operation: info.document, + schema, + useRequestBody: ['POST', 'PUT', 'PATCH'].includes(info.method), + tags: info.tags || [], + description: info.description || '', + customScalars, + }); + }, + get() { + return swagger; + }, + }; +} diff --git a/.bob/esm/src/open-api/operations.d.ts b/.bob/esm/src/open-api/operations.d.ts new file mode 100644 index 00000000..93bdd7bd --- /dev/null +++ b/.bob/esm/src/open-api/operations.d.ts @@ -0,0 +1,29 @@ +import { DocumentNode, GraphQLSchema, OperationDefinitionNode, TypeNode, VariableDefinitionNode } from 'graphql'; +import { OpenAPIV3 } from 'openapi-types'; +export declare function buildPathFromOperation({ url, schema, operation, useRequestBody, tags, description, customScalars, }: { + url: string; + schema: GraphQLSchema; + operation: DocumentNode; + useRequestBody: boolean; + tags?: string[]; + description?: string; + customScalars: Record; +}): OpenAPIV3.OperationObject; +export declare function resolveRequestBody(variables: ReadonlyArray | undefined, schema: GraphQLSchema, operation: OperationDefinitionNode, opts: { + customScalars: Record; + enumTypes: Record; +}): {}; +export declare function resolveParamSchema(type: TypeNode, opts: { + customScalars: Record; + enumTypes: Record; +}): any; +export declare function resolveResponse({ schema, operation, opts, }: { + schema: GraphQLSchema; + operation: OperationDefinitionNode; + opts: { + customScalars: Record; + enumTypes: Record; + }; +}): any; +export declare function isInPath(url: string, param: string): boolean; +export declare function resolveVariableDescription(schema: GraphQLSchema, operation: OperationDefinitionNode, variable: VariableDefinitionNode): string | undefined; diff --git a/.bob/esm/src/open-api/operations.js b/.bob/esm/src/open-api/operations.js new file mode 100644 index 00000000..23b9c0fa --- /dev/null +++ b/.bob/esm/src/open-api/operations.js @@ -0,0 +1,155 @@ +import { isEnumType, Kind, parse, printType, } from 'graphql'; +import { getOperationInfo } from '../ast'; +import { mapToPrimitive, mapToRef } from './utils'; +import { resolveFieldType } from './types'; +import { titleCase } from 'title-case'; +export function buildPathFromOperation({ url, schema, operation, useRequestBody, tags, description, customScalars, }) { + const info = getOperationInfo(operation); + const enumTypes = resolveEnumTypes(schema); + const summary = resolveDescription(schema, info.operation); + const variables = info.operation.variableDefinitions; + const pathParams = variables?.filter((variable) => isInPath(url, variable.variable.name.value)); + const bodyParams = variables?.filter((variable) => !isInPath(url, variable.variable.name.value)); + return { + tags, + description, + summary, + operationId: info.name, + ...(useRequestBody + ? { + parameters: resolveParameters(url, pathParams, schema, info.operation, { customScalars, enumTypes }), + requestBody: { + content: { + 'application/json': { + schema: resolveRequestBody(bodyParams, schema, info.operation, { customScalars, enumTypes }), + }, + }, + }, + } + : { + parameters: resolveParameters(url, variables, schema, info.operation, { customScalars, enumTypes }), + }), + responses: { + 200: { + description: summary, + content: { + 'application/json': { + schema: resolveResponse({ + schema, + operation: info.operation, + opts: { customScalars, enumTypes }, + }), + }, + }, + }, + }, + }; +} +function resolveEnumTypes(schema) { + const enumTypes = Object.values(schema.getTypeMap()) + .filter(isEnumType); + return Object.fromEntries(enumTypes.map((type) => [ + type.name, + { + type: 'string', + enum: type.getValues().map((value) => value.name), + }, + ])); +} +function resolveParameters(url, variables, schema, operation, opts) { + if (!variables) { + return []; + } + return variables.map((variable) => { + return { + in: isInPath(url, variable.variable.name.value) ? 'path' : 'query', + name: variable.variable.name.value, + required: variable.type.kind === Kind.NON_NULL_TYPE, + schema: resolveParamSchema(variable.type, opts), + description: resolveVariableDescription(schema, operation, variable), + }; + }); +} +export function resolveRequestBody(variables, schema, operation, opts) { + if (!variables) { + return {}; + } + const properties = {}; + const required = []; + variables.forEach((variable) => { + if (variable.type.kind === Kind.NON_NULL_TYPE) { + required.push(variable.variable.name.value); + } + properties[variable.variable.name.value] = { + ...resolveParamSchema(variable.type, opts), + description: resolveVariableDescription(schema, operation, variable), + }; + }); + return { + type: 'object', + properties, + ...(required.length ? { required } : {}), + }; +} +// array -> [type] +// type -> $ref +// scalar -> swagger primitive +export function resolveParamSchema(type, opts) { + if (type.kind === Kind.NON_NULL_TYPE) { + return resolveParamSchema(type.type, opts); + } + if (type.kind === Kind.LIST_TYPE) { + return { + type: 'array', + items: resolveParamSchema(type.type, opts), + }; + } + const primitive = mapToPrimitive(type.name.value); + return (primitive || + opts.customScalars[type.name.value] || + opts.enumTypes[type.name.value] || { $ref: mapToRef(type.name.value) }); +} +export function resolveResponse({ schema, operation, opts, }) { + const operationType = operation.operation; + const rootField = operation.selectionSet.selections[0]; + if (rootField.kind === Kind.FIELD) { + if (operationType === 'query') { + const queryType = schema.getQueryType(); + const field = queryType.getFields()[rootField.name.value]; + return resolveFieldType(field.type, opts); + } + if (operationType === 'mutation') { + const mutationType = schema.getMutationType(); + const field = mutationType.getFields()[rootField.name.value]; + return resolveFieldType(field.type, opts); + } + } +} +export function isInPath(url, param) { + return url.includes(`:${param}`) || url.includes(`{${param}}`); +} +function getOperationFieldNode(schema, operation) { + const selection = operation.selectionSet.selections[0]; + const fieldName = selection.name.value; + const typeDefinition = schema.getType(titleCase(operation.operation)); + if (!typeDefinition) { + return undefined; + } + const definitionNode = typeDefinition.astNode || parse(printType(typeDefinition)).definitions[0]; + if (!isObjectTypeDefinitionNode(definitionNode)) { + return undefined; + } + return definitionNode.fields.find((field) => field.name.value === fieldName); +} +function resolveDescription(schema, operation) { + const fieldNode = getOperationFieldNode(schema, operation); + return fieldNode?.description?.value || ''; +} +export function resolveVariableDescription(schema, operation, variable) { + const fieldNode = getOperationFieldNode(schema, operation); + const argument = fieldNode?.arguments?.find((arg) => arg.name.value === variable.variable.name.value); + return argument?.description?.value; +} +function isObjectTypeDefinitionNode(node) { + return node.kind === Kind.OBJECT_TYPE_DEFINITION; +} diff --git a/.bob/esm/src/open-api/types.d.ts b/.bob/esm/src/open-api/types.d.ts new file mode 100644 index 00000000..43d3f369 --- /dev/null +++ b/.bob/esm/src/open-api/types.d.ts @@ -0,0 +1,7 @@ +import { GraphQLObjectType, GraphQLInputObjectType, GraphQLType } from 'graphql'; +export declare function buildSchemaObjectFromType(type: GraphQLObjectType | GraphQLInputObjectType, opts: { + customScalars: Record; +}): any; +export declare function resolveFieldType(type: GraphQLType, opts: { + customScalars: Record; +}): any; diff --git a/.bob/esm/src/open-api/types.js b/.bob/esm/src/open-api/types.js new file mode 100644 index 00000000..ec06c8ec --- /dev/null +++ b/.bob/esm/src/open-api/types.js @@ -0,0 +1,62 @@ +import { isNonNullType, isListType, isObjectType, isScalarType, isEnumType, } from 'graphql'; +import { mapToPrimitive, mapToRef } from './utils'; +export function buildSchemaObjectFromType(type, opts) { + const required = []; + const properties = {}; + const fields = type.getFields(); + for (const fieldName in fields) { + const field = fields[fieldName]; + if (isNonNullType(field.type)) { + required.push(field.name); + } + properties[fieldName] = resolveField(field, opts); + if (field.description) { + properties[fieldName].description = field.description; + } + } + return { + type: 'object', + ...(required.length ? { required } : {}), + properties, + ...(type.description ? { description: type.description } : {}), + }; +} +function resolveField(field, opts) { + return resolveFieldType(field.type, opts); +} +// array -> [type] +// type -> $ref +// scalar -> swagger primitive +export function resolveFieldType(type, opts) { + if (isNonNullType(type)) { + return resolveFieldType(type.ofType, opts); + } + if (isListType(type)) { + return { + type: 'array', + items: resolveFieldType(type.ofType, opts), + }; + } + if (isObjectType(type)) { + return { + $ref: mapToRef(type.name), + }; + } + if (isScalarType(type)) { + const resolved = mapToPrimitive(type.name) || + opts.customScalars[type.name] || + type.extensions?.jsonSchema || { + type: 'object', + }; + return { ...resolved }; + } + if (isEnumType(type)) { + return { + type: 'string', + enum: type.getValues().map((value) => value.name), + }; + } + return { + type: 'object', + }; +} diff --git a/.bob/esm/src/open-api/utils.d.ts b/.bob/esm/src/open-api/utils.d.ts new file mode 100644 index 00000000..ccfed6dd --- /dev/null +++ b/.bob/esm/src/open-api/utils.d.ts @@ -0,0 +1,3 @@ +export declare function mapToPrimitive(type: string): any; +export declare function mapToRef(type: string): string; +export declare function normalizePathParamForOpenAPI(path: string): string; diff --git a/.bob/esm/src/open-api/utils.js b/.bob/esm/src/open-api/utils.js new file mode 100644 index 00000000..8f09d9d5 --- /dev/null +++ b/.bob/esm/src/open-api/utils.js @@ -0,0 +1,37 @@ +export function mapToPrimitive(type) { + const formatMap = { + Int: { + type: 'integer', + format: 'int32', + }, + Float: { + type: 'number', + format: 'float', + }, + String: { + type: 'string', + }, + Boolean: { + type: 'boolean', + }, + ID: { + type: 'string', + }, + }; + if (formatMap[type]) { + return formatMap[type]; + } +} +export function mapToRef(type) { + return `#/components/schemas/${type}`; +} +export function normalizePathParamForOpenAPI(path) { + const pathParts = path.split('/'); + const normalizedPathParts = pathParts.map((part) => { + if (part.startsWith(':')) { + return `{${part.slice(1)}}`; + } + return part; + }); + return normalizedPathParts.join('/'); +} diff --git a/.bob/esm/src/parse.d.ts b/.bob/esm/src/parse.d.ts new file mode 100644 index 00000000..e22c81a7 --- /dev/null +++ b/.bob/esm/src/parse.d.ts @@ -0,0 +1,6 @@ +import { type VariableDefinitionNode, type GraphQLSchema } from 'graphql'; +export declare function parseVariable({ value, variable, schema, }: { + value: any; + variable: VariableDefinitionNode; + schema: GraphQLSchema; +}): any; diff --git a/.bob/esm/src/parse.js b/.bob/esm/src/parse.js new file mode 100644 index 00000000..4cde947f --- /dev/null +++ b/.bob/esm/src/parse.js @@ -0,0 +1,42 @@ +import { isScalarType, isEqualType, GraphQLBoolean, isInputObjectType, Kind, } from 'graphql'; +import { isNil } from './common.js'; +export function parseVariable({ value, variable, schema, }) { + if (isNil(value)) { + return; + } + return resolveVariable({ + value, + type: variable.type, + schema, + }); +} +function resolveVariable({ value, type, schema, }) { + if (type.kind === Kind.NAMED_TYPE) { + const namedType = schema.getType(type.name.value); + if (isScalarType(namedType)) { + // GraphQLBoolean.serialize expects a boolean or a number only + if (isEqualType(GraphQLBoolean, namedType)) { + value = (value === 'true' || value === true); + } + return namedType.serialize(value); + } + if (isInputObjectType(namedType)) { + return value && typeof value === 'object' ? value : JSON.parse(value); + } + return value; + } + if (type.kind === Kind.LIST_TYPE) { + return (Array.isArray(value) ? value : [value]).map((val) => resolveVariable({ + value: val, + type: type.type, + schema, + })); + } + if (type.kind === Kind.NON_NULL_TYPE) { + return resolveVariable({ + value: value, + type: type.type, + schema, + }); + } +} diff --git a/.bob/esm/src/router.d.ts b/.bob/esm/src/router.d.ts new file mode 100644 index 00000000..1df90ade --- /dev/null +++ b/.bob/esm/src/router.d.ts @@ -0,0 +1,16 @@ +import type { Sofa } from './sofa.js'; +import { Response, type Router } from 'fets'; +export type ErrorHandler = (errors: ReadonlyArray) => Response; +declare module 'graphql' { + interface GraphQLHTTPErrorExtensions { + spec?: boolean; + status?: number; + headers?: Record; + } + interface GraphQLErrorExtensions { + http?: GraphQLHTTPErrorExtensions; + } +} +export declare function createRouter(sofa: Sofa): Router; diff --git a/.bob/esm/src/router.js b/.bob/esm/src/router.js new file mode 100644 index 00000000..cec93aa6 --- /dev/null +++ b/.bob/esm/src/router.js @@ -0,0 +1,382 @@ +import { isObjectType, isNonNullType, Kind, isIntrospectionType, isInputObjectType, } from 'graphql'; +import { buildOperationNodeForField, createGraphQLError } from '@graphql-tools/utils'; +import { getOperationInfo } from './ast.js'; +import { convertName } from './common'; +import { parseVariable } from './parse.js'; +import { SubscriptionManager } from './subscriptions.js'; +import { logger } from './logger.js'; +import { Response, createRouter as createRouterInstance, } from 'fets'; +import { isInPath, resolveParamSchema, resolveRequestBody, resolveResponse, resolveVariableDescription } from './open-api/operations.js'; +import { buildSchemaObjectFromType } from './open-api/types.js'; +const defaultErrorHandler = (errors) => { + let status; + const headers = { + 'Content-Type': 'application/json; charset=utf-8', + }; + for (const error of errors) { + if (typeof error === 'object' && + error != null && + error.extensions?.http) { + if (error.extensions.http.status && + (!status || error.extensions.http.status > status)) { + status = error.extensions.http.status; + } + if (error.extensions.http.headers) { + Object.assign(headers, error.extensions.http.headers); + } + delete error.extensions.http; + } + } + if (!status) { + status = 500; + } + return Response.json({ errors }, { + status, + headers, + }); +}; +function useRequestBody(method) { + return method === 'POST' || method === 'PUT' || method === 'PATCH'; +} +export function createRouter(sofa) { + logger.debug('[Sofa] Creating router'); + sofa.openAPI ||= {}; + sofa.openAPI.info ||= {}; + sofa.openAPI.info.title ||= 'SOFA API'; + sofa.openAPI.info.description ||= 'Generated by SOFA'; + sofa.openAPI.info.version ||= '0.0.0'; + sofa.openAPI.components ||= {}; + sofa.openAPI.components.schemas ||= {}; + const types = sofa.schema.getTypeMap(); + for (const typeName in types) { + const type = types[typeName]; + if ((isObjectType(type) || isInputObjectType(type)) && + !isIntrospectionType(type)) { + sofa.openAPI.components.schemas[typeName] = buildSchemaObjectFromType(type, { + customScalars: sofa.customScalars, + }); + } + } + const router = createRouterInstance({ + base: sofa.basePath, + openAPI: sofa.openAPI, + swaggerUI: sofa.swaggerUI, + landingPage: false, + }); + const queryType = sofa.schema.getQueryType(); + const mutationType = sofa.schema.getMutationType(); + const subscriptionManager = new SubscriptionManager(sofa); + if (queryType) { + Object.keys(queryType.getFields()).forEach((fieldName) => { + createQueryRoute({ sofa, router, fieldName }); + }); + } + if (mutationType) { + Object.keys(mutationType.getFields()).forEach((fieldName) => { + createMutationRoute({ sofa, router, fieldName }); + }); + } + router.route({ + path: '/webhook', + method: 'POST', + async handler(request, serverContext) { + const { subscription, variables, url } = await request.json(); + try { + const sofaContext = Object.assign(serverContext, { + request, + }); + const result = await subscriptionManager.start({ + subscription, + variables, + url, + }, sofaContext); + return Response.json(result); + } + catch (error) { + return Response.json(error, { + status: 500, + statusText: 'Subscription failed', + }); + } + } + }); + router.route({ + path: '/webhook/:id', + method: 'POST', + async handler(request, serverContext) { + const id = request.params?.id; + const body = await request.json(); + const variables = body.variables; + try { + const sofaContext = Object.assign(serverContext, { + request, + }); + const contextValue = await sofa.contextFactory(sofaContext); + const result = await subscriptionManager.update({ + id, + variables, + }, contextValue); + return Response.json(result); + } + catch (error) { + return Response.json(error, { + status: 500, + statusText: 'Subscription failed to update', + }); + } + } + }); + router.route({ + path: '/webhook/:id', + method: 'DELETE', + async handler(request) { + const id = request.params?.id; + try { + const result = await subscriptionManager.stop(id); + return Response.json(result); + } + catch (error) { + return Response.json(error, { + status: 500, + statusText: 'Subscription failed to stop', + }); + } + } + }); + return router; +} +function createQueryRoute({ sofa, router, fieldName, }) { + logger.debug(`[Router] Creating ${fieldName} query`); + const queryType = sofa.schema.getQueryType(); + const operationNode = buildOperationNodeForField({ + kind: 'query', + schema: sofa.schema, + field: fieldName, + models: sofa.models, + ignore: sofa.ignore, + circularReferenceDepth: sofa.depthLimit, + }); + const operation = { + kind: Kind.DOCUMENT, + definitions: [operationNode], + }; + const info = getOperationInfo(operation); + const field = queryType.getFields()[fieldName]; + const fieldType = field.type; + const isSingle = isObjectType(fieldType) || + (isNonNullType(fieldType) && isObjectType(fieldType.ofType)); + const hasIdArgument = field.args.some((arg) => arg.name === 'id'); + const graphqlPath = `${queryType.name}.${fieldName}`; + const routeConfig = sofa.routes?.[graphqlPath]; + const route = { + method: routeConfig?.method ?? 'GET', + path: routeConfig?.path ?? getPath(fieldName, isSingle && hasIdArgument), + responseStatus: routeConfig?.responseStatus ?? 200, + }; + router.route({ + path: route.path, + method: route.method, + schemas: getRouteSchemas({ + method: route.method, + path: route.path, + info, + sofa, + responseStatus: route.responseStatus, + }), + handler: useHandler({ info, route, fieldName, sofa, operation }), + }); + logger.debug(`[Router] ${fieldName} query available at ${route.method} ${route.path}`); + return { + document: operation, + path: route.path, + method: route.method.toUpperCase(), + tags: routeConfig?.tags ?? [], + description: routeConfig?.description ?? field.description ?? '', + }; +} +function getRouteSchemas({ method, path, info, sofa, responseStatus, }) { + const params = { + properties: {}, + required: [], + }; + const query = { + properties: {}, + required: [], + }; + for (const variable of info.variables) { + const varSchema = resolveParamSchema(variable.type, { + customScalars: sofa.customScalars, + enumTypes: sofa.enumTypes, + }); + varSchema.description = resolveVariableDescription(sofa.schema, info.operation, variable); + const varName = variable.variable.name.value; + const varObj = isInPath(path, varName) ? params : query; + varObj.properties[varName] = varSchema; + if (variable.type.kind === Kind.NON_NULL_TYPE) { + varObj.required.push(varName); + } + } + return { + request: { + json: useRequestBody(method) ? resolveRequestBody(info.variables, sofa.schema, info.operation, { + customScalars: sofa.customScalars, + enumTypes: sofa.enumTypes, + }) : undefined, + params, + query, + }, + responses: { + [responseStatus]: resolveResponse({ + schema: sofa.schema, + operation: info.operation, + opts: { + customScalars: sofa.customScalars, + enumTypes: sofa.enumTypes, + } + }) + } + }; +} +function createMutationRoute({ sofa, router, fieldName, }) { + logger.debug(`[Router] Creating ${fieldName} mutation`); + const mutationType = sofa.schema.getMutationType(); + const field = mutationType.getFields()[fieldName]; + const operationNode = buildOperationNodeForField({ + kind: 'mutation', + schema: sofa.schema, + field: fieldName, + models: sofa.models, + ignore: sofa.ignore, + circularReferenceDepth: sofa.depthLimit, + }); + const operation = { + kind: Kind.DOCUMENT, + definitions: [operationNode], + }; + const info = getOperationInfo(operation); + const graphqlPath = `${mutationType.name}.${fieldName}`; + const routeConfig = sofa.routes?.[graphqlPath]; + const method = routeConfig?.method ?? 'POST'; + const path = routeConfig?.path ?? getPath(fieldName); + const responseStatus = routeConfig?.responseStatus ?? 200; + const route = { + method, + path, + responseStatus, + }; + router.route({ + method, + path, + schemas: getRouteSchemas({ + method, + path, + info, + responseStatus, + sofa, + }), + handler: useHandler({ info, route, fieldName, sofa, operation }), + }); + logger.debug(`[Router] ${fieldName} mutation available at ${method} ${path}`); + return { + document: operation, + path, + method, + tags: routeConfig?.tags || [], + description: routeConfig?.description ?? field.description ?? '', + }; +} +function useHandler(config) { + const { sofa, operation, fieldName } = config; + const info = config.info; + const errorHandler = sofa.errorHandler || defaultErrorHandler; + return async (request, serverContext) => { + try { + let body = {}; + if (request.body != null) { + const strBody = await request.text(); + if (strBody) { + try { + body = JSON.parse(strBody); + } + catch (error) { + throw createGraphQLError('POST body sent invalid JSON.', { + extensions: { + http: { + status: 400, + } + } + }); + } + } + } + let variableValues = {}; + try { + variableValues = info.variables.reduce((variables, variable) => { + const name = variable.variable.name.value; + const value = parseVariable({ + value: pickParam({ + url: request.url, + body, + params: request.params || {}, + name, + }), + variable, + schema: sofa.schema, + }); + if (typeof value === 'undefined') { + return variables; + } + return { + ...variables, + [name]: value, + }; + }, {}); + } + catch (error) { + throw createGraphQLError(error.message || error.toString?.() || error, { + extensions: { + http: { + status: 400, + } + } + }); + } + const sofaContext = Object.assign(serverContext, { + request, + }); + const contextValue = await sofa.contextFactory(sofaContext); + const result = await sofa.execute({ + schema: sofa.schema, + document: operation, + contextValue, + variableValues, + operationName: info.operation.name && info.operation.name.value, + }); + if (result.errors) { + return errorHandler(result.errors); + } + return Response.json(result.data?.[fieldName], { + status: config.route.responseStatus, + }); + } + catch (error) { + return errorHandler([error]); + } + }; +} +function getPath(fieldName, hasId = false) { + return `/${convertName(fieldName)}${hasId ? '/:id' : ''}`; +} +function pickParam({ name, url, params, body, }) { + if (name in params) { + return params[name]; + } + const searchParams = new URLSearchParams(url.split('?')[1]); + if (searchParams.has(name)) { + const values = searchParams.getAll(name); + return values.length === 1 ? values[0] : values; + } + if (body && body.hasOwnProperty(name)) { + return body[name]; + } +} diff --git a/.bob/esm/src/sofa.d.ts b/.bob/esm/src/sofa.d.ts new file mode 100644 index 00000000..3be9ec37 --- /dev/null +++ b/.bob/esm/src/sofa.d.ts @@ -0,0 +1,57 @@ +import { GraphQLSchema, subscribe, execute } from 'graphql'; +import type { Ignore, ContextFn, ContextValue } from './types.js'; +import type { ErrorHandler } from './router.js'; +import type { HTTPMethod, StatusCode } from 'fets/typings/typed-fetch'; +import type { RouterOpenAPIOptions, RouterSwaggerUIOptions } from 'fets'; +export interface RouteConfig { + method?: HTTPMethod; + path?: string; + responseStatus?: StatusCode; + tags?: string[]; + description?: string; +} +export interface Route { + method: HTTPMethod; + path: string; + responseStatus: StatusCode; +} +export interface SofaConfig { + basePath: string; + schema: GraphQLSchema; + execute?: typeof execute; + subscribe?: typeof subscribe; + /** + * Treats an Object with an ID as not a model. + * @example ["User", "Message.author"] + */ + ignore?: Ignore; + depthLimit?: number; + errorHandler?: ErrorHandler; + /** + * Overwrites the default HTTP route. + */ + routes?: Record; + context?: ContextFn | ContextValue; + customScalars?: Record; + enumTypes?: Record; + openAPI?: RouterOpenAPIOptions; + swaggerUI?: RouterSwaggerUIOptions; +} +export interface Sofa { + basePath: string; + schema: GraphQLSchema; + models: string[]; + ignore: Ignore; + depthLimit: number; + routes?: Record; + execute: typeof execute; + subscribe: typeof subscribe; + errorHandler?: ErrorHandler; + contextFactory: ContextFn; + customScalars: Record; + enumTypes: Record; + openAPI?: RouterOpenAPIOptions; + swaggerUI?: RouterSwaggerUIOptions; +} +export declare function createSofa(config: SofaConfig): Sofa; +export declare function isContextFn(context: any): context is ContextFn; diff --git a/.bob/esm/src/sofa.js b/.bob/esm/src/sofa.js new file mode 100644 index 00000000..6d853244 --- /dev/null +++ b/.bob/esm/src/sofa.js @@ -0,0 +1,87 @@ +import { isObjectType, getNamedType, isNonNullType, subscribe, execute, } from 'graphql'; +import { convertName } from './common.js'; +import { logger } from './logger.js'; +export function createSofa(config) { + logger.debug('[Sofa] Created'); + const models = extractsModels(config.schema); + const ignore = config.ignore || []; + const depthLimit = config.depthLimit || 1; + logger.debug(`[Sofa] models: ${models.join(', ')}`); + logger.debug(`[Sofa] ignore: ${ignore.join(', ')}`); + return { + execute, + subscribe, + models, + ignore, + depthLimit, + contextFactory(serverContext) { + if (config.context != null) { + if (isContextFn(config.context)) { + return config.context(serverContext); + } + else { + return config.context; + } + } + return serverContext; + }, + customScalars: config.customScalars || {}, + enumTypes: config.enumTypes || {}, + ...config, + }; +} +export function isContextFn(context) { + return typeof context === 'function'; +} +// Objects and Unions are the only things that are used to define return types +// and both might contain an ID +// We don't treat Unions as models because +// they might represent an Object that is not a model +// We check it later, when an operation is being built +function extractsModels(schema) { + const modelMap = {}; + const query = schema.getQueryType(); + const fields = query.getFields(); + // if Query[type] (no args) and Query[type](just id as an argument) + // loop through every field + for (const fieldName in fields) { + const field = fields[fieldName]; + const namedType = getNamedType(field.type); + if (hasID(namedType)) { + if (!modelMap[namedType.name]) { + modelMap[namedType.name] = {}; + } + if (isArrayOf(field.type, namedType)) { + // check if type is a list + // check if name of a field matches a name of a named type (in plural) + // check if has no non-optional arguments + // add to registry with `list: true` + const sameName = isNameEqual(field.name, namedType.name + 's'); + const allOptionalArguments = !field.args.some((arg) => isNonNullType(arg.type)); + modelMap[namedType.name].list ||= sameName && allOptionalArguments; + } + else if (isObjectType(field.type) || + (isNonNullType(field.type) && isObjectType(field.type.ofType))) { + // check if type is a graphql object type + // check if name of a field matches with name of an object type + // check if has only one argument named `id` + // add to registry with `single: true` + const sameName = isNameEqual(field.name, namedType.name); + const hasIdArgument = field.args.length === 1 && field.args[0].name === 'id'; + modelMap[namedType.name].single ||= sameName && hasIdArgument; + } + } + } + return Object.keys(modelMap).filter((name) => modelMap[name].list && modelMap[name].single); +} +// it's dumb but let's leave it for now +function isArrayOf(type, expected) { + const typeNameInSdl = type.toString(); + return (typeNameInSdl.includes('[') && typeNameInSdl.includes(expected.toString())); +} +function hasID(type) { + return isObjectType(type) && !!type.getFields().id; +} +function isNameEqual(a, b) { + return convertName(a) === convertName(b); +} diff --git a/.bob/esm/src/subscriptions.d.ts b/.bob/esm/src/subscriptions.d.ts new file mode 100644 index 00000000..6c594b09 --- /dev/null +++ b/.bob/esm/src/subscriptions.d.ts @@ -0,0 +1,33 @@ +import { type ExecutionResult } from 'graphql'; +import type { ContextValue } from './types.js'; +import type { Sofa } from './sofa.js'; +export type ID = string; +export type SubscriptionFieldName = string; +export interface StartSubscriptionEvent { + subscription: SubscriptionFieldName; + variables: any; + url: string; +} +export interface UpdateSubscriptionEvent { + id: ID; + variables: any; +} +export interface StopSubscriptionResponse { + id: ID; +} +export declare class SubscriptionManager { + private sofa; + private operations; + private clients; + constructor(sofa: Sofa); + start(event: StartSubscriptionEvent, contextValue: ContextValue): Promise, import("graphql/jsutils/ObjMap.js").ObjMap> | { + id: `${string}-${string}-${string}-${string}-${string}`; + }>; + stop(id: ID): Promise; + update(event: UpdateSubscriptionEvent, contextValue: ContextValue): Promise, import("graphql/jsutils/ObjMap.js").ObjMap> | { + id: `${string}-${string}-${string}-${string}-${string}`; + }>; + private execute; + private sendData; + private buildOperations; +} diff --git a/.bob/esm/src/subscriptions.js b/.bob/esm/src/subscriptions.js new file mode 100644 index 00000000..084fc03b --- /dev/null +++ b/.bob/esm/src/subscriptions.js @@ -0,0 +1,156 @@ +import { Kind, } from 'graphql'; +import { fetch, crypto } from '@whatwg-node/fetch'; +import { buildOperationNodeForField } from '@graphql-tools/utils'; +import { getOperationInfo } from './ast.js'; +import { parseVariable } from './parse.js'; +import { logger } from './logger.js'; +function isAsyncIterable(obj) { + return typeof obj[Symbol.asyncIterator] === 'function'; +} +export class SubscriptionManager { + constructor(sofa) { + this.sofa = sofa; + this.operations = new Map(); + this.clients = new Map(); + this.buildOperations(); + } + async start(event, contextValue) { + const id = crypto.randomUUID(); + const name = event.subscription; + if (!this.operations.has(name)) { + throw new Error(`Subscription '${name}' is not available`); + } + logger.info(`[Subscription] Start ${id}`, event); + const result = await this.execute({ + id, + name, + url: event.url, + variables: event.variables, + contextValue, + }); + if (typeof result !== 'undefined') { + return result; + } + return { id }; + } + async stop(id) { + logger.info(`[Subscription] Stop ${id}`); + if (!this.clients.has(id)) { + throw new Error(`Subscription with ID '${id}' does not exist`); + } + const execution = this.clients.get(id); + if (execution.iterator.return) { + execution.iterator.return(); + } + this.clients.delete(id); + return { id }; + } + async update(event, contextValue) { + const { variables, id } = event; + logger.info(`[Subscription] Update ${id}`, event); + if (!this.clients.has(id)) { + throw new Error(`Subscription with ID '${id}' does not exist`); + } + const { name: subscription, url } = this.clients.get(id); + this.stop(id); + return this.start({ + url, + subscription, + variables, + }, contextValue); + } + async execute({ id, name, url, variables, contextValue, }) { + const { document, operationName, variables: variableNodes } = this.operations.get(name); + const variableValues = variableNodes.reduce((values, variable) => { + const value = parseVariable({ + value: variables[variable.variable.name.value], + variable, + schema: this.sofa.schema, + }); + if (typeof value === 'undefined') { + return values; + } + return { + ...values, + [variable.variable.name.value]: value, + }; + }, {}); + const execution = await this.sofa.subscribe({ + schema: this.sofa.schema, + document, + operationName, + variableValues, + contextValue, + }); + if (isAsyncIterable(execution)) { + // successful + // add execution to clients + this.clients.set(id, { + name, + url, + iterator: execution, + }); + // success + (async () => { + for await (const result of execution) { + await this.sendData({ + id, + result, + }); + } + })().then(() => { + // completes + this.clients.delete(id); + }, (e) => { + logger.info(`Subscription #${id} closed`); + logger.error(e); + this.clients.delete(id); + }); + } + else { + return execution; + } + } + async sendData({ id, result }) { + if (!this.clients.has(id)) { + throw new Error(`Subscription with ID '${id}' does not exist`); + } + const { url } = this.clients.get(id); + logger.info(`[Subscription] Trigger ${id}`); + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(result), + headers: { + 'Content-Type': 'application/json', + }, + }); + await response.text(); + } + buildOperations() { + const subscription = this.sofa.schema.getSubscriptionType(); + if (!subscription) { + return; + } + const fieldMap = subscription.getFields(); + for (const field in fieldMap) { + const operationNode = buildOperationNodeForField({ + kind: 'subscription', + field, + schema: this.sofa.schema, + models: this.sofa.models, + ignore: this.sofa.ignore, + circularReferenceDepth: this.sofa.depthLimit, + }); + const document = { + kind: Kind.DOCUMENT, + definitions: [operationNode], + }; + const { variables, name: operationName } = getOperationInfo(document); + this.operations.set(field, { + operationName, + document, + variables, + }); + } + } +} diff --git a/.bob/esm/src/types.d.ts b/.bob/esm/src/types.d.ts new file mode 100644 index 00000000..ac8410a2 --- /dev/null +++ b/.bob/esm/src/types.d.ts @@ -0,0 +1,15 @@ +import type { HTTPMethod } from 'fets/typings/typed-fetch'; +import type { DocumentNode } from 'graphql'; +export type ContextValue = Record; +export type Ignore = string[]; +export interface RouteInfo { + document: DocumentNode; + path: string; + method: HTTPMethod; + tags?: string[]; + description?: string; +} +export type ContextFn = (serverContext: DefaultSofaServerContext) => Promise | ContextValue; +export type DefaultSofaServerContext = { + request: Request; +}; diff --git a/.bob/esm/src/types.js b/.bob/esm/src/types.js new file mode 100644 index 00000000..e69de29b diff --git a/src/router.ts b/src/router.ts index 0194c41c..a6991aad 100644 --- a/src/router.ts +++ b/src/router.ts @@ -247,6 +247,7 @@ function createQueryRoute({ }; router.route({ + operationId: operationNode.name?.value, path: route.path, method: route.method, schemas: getRouteSchemas({