import { CallExpressionShape, DeletedShape, type NowConfig, type Diagnostics, type Factory, Plugin, type Record, type Shape, } from '@servicenow/sdk-build-core' import { ModuleFunctionShape } from './server-module-plugin' import { NowIdShape } from './now-id-plugin' import { NowIncludeShape } from './now-include-plugin' import { RecordPlugin } from './record-plugin' import { validateServerScriptField } from './utils' const DEFAULT_CONTEXTUAL_ACL_MAX_DEPTH = 4 const MAX_APPLICATION_NAMESPACE_LENGTH = 40 const GRAPHQL_NAME = /^[_A-Za-z][_0-9A-Za-z]*$/ // Ports the platform's GraphQLApplicationNamespace script include: it is the dictionary default // for application_namespace, and dictionary defaults do not run on the install path. function applicationNamespaceFromScope(scope: string) { if (scope === 'global') { return 'now' } return scope.replace(/[-_][A-Za-z]/g, (boundary) => boundary.toUpperCase()).replace(/[^a-z0-9]/gi, '') } function resolverRefId(mapping: Record): string { const ref = mapping.get('resolver') return ref.ifString()?.getValue() ?? ref.ifRecordId()?.getValue() ?? '' } function mergeAcls(acls: Shape | undefined) { if (acls?.isArray()) { return acls.pipe((r) => Array.from(new Set(r.getElements().map((r) => r.ifRecord()?.getId().getValue() ?? r.getValue()))).join(',') ) } return '' } function splitAcls(acls: Shape | undefined) { if (acls?.isString() && !acls?.isEmpty()) { return acls.pipe((r) => r.getValue().split(',')) } return [] } function reportDuplicateValues(values: Shape[], buildMessage: (value: string) => string, diagnostics: Diagnostics) { const seen = new Set() for (const value of values) { const text = value.asString().getValue() if (seen.has(text)) { diagnostics.error(value, buildMessage(text)) } seen.add(text) } } async function generateResolverRecords( resolvers: Shape[], schemaRecord: Record, factory: Factory, config: NowConfig, diagnostics: Diagnostics ): Promise { return Promise.all( resolvers.map(async (r) => { const resolver = r.asObject() validateServerScriptField(resolver.get('script'), diagnostics, config.serverModulesDir) const resolverRecord = await factory.createRecord({ source: resolver, table: 'sys_graphql_resolver', explicitId: resolver.get('$id'), properties: resolver.transform(({ $ }) => ({ name: $, script: $.from('script') .map((v) => v.if(ModuleFunctionShape)?.toString((n) => `${n}({{PARAMS}})`, ['env']) ?? v) .toCdata(), schema: $.val(schemaRecord.getId()), })), }) const paths = resolver.get('paths')?.ifArray()?.getElements() ?? [] const mappingRecords = await Promise.all( paths.map((path) => factory.createRecord({ source: path, table: 'sys_graphql_resolver_mapping', properties: { path: path, resolver: resolverRecord.getId(), schema: schemaRecord.getId(), }, }) ) ) return resolverRecord.with(...mappingRecords) }) ) } async function generateTypeResolverRecords( typeResolvers: Shape[], schemaRecord: Record, factory: Factory, config: NowConfig, diagnostics: Diagnostics ): Promise { return Promise.all( typeResolvers.map((t) => { const typeResolver = t.asObject() validateServerScriptField(typeResolver.get('script'), diagnostics, config.serverModulesDir) return factory.createRecord({ source: typeResolver, table: 'sys_graphql_typeresolver', explicitId: typeResolver.get('$id'), properties: typeResolver.transform(({ $ }) => ({ type_name: $.from('typeName'), script: $.from('script') .map((v) => v.if(ModuleFunctionShape)?.toString((n) => `${n}({{PARAMS}})`, ['env']) ?? v) .toCdata(), schema: $.val(schemaRecord.getId()), })), }) }) ) } export const GraphQLApiPlugin = Plugin.create({ name: 'GraphQLApiPlugin', records: { sys_graphql_resolver_mapping: { coalesce: ['schema', 'path'], }, sys_graphql_schema: { relationships: { sys_graphql_resolver: { via: 'schema', descendant: true, }, sys_graphql_resolver_mapping: { via: 'schema', descendant: true, }, sys_graphql_typeresolver: { via: 'schema', descendant: true, }, }, inspect(record, { database, diagnostics, self }) { if (record.getCreator()?.getName() !== self.getName()) { return } const namespace = record.get('namespace').asString().getValue() const applicationNamespace = record.get('application_namespace').asString().getValue() const sharingNamespace = database.query('sys_graphql_schema', { namespace, application_namespace: applicationNamespace, }) if (sharingNamespace[0]?.getId().getValue() === record.getId().getValue()) { return } diagnostics.error( record, `Namespace "${namespace}" is already used by another GraphQL API in application namespace "${applicationNamespace}". Each API in an application namespace must have its own namespace.` ) }, async toShape(record, { descendants, transform, logger, config }) { const resolvers = descendants.query('sys_graphql_resolver') const mappings = descendants.query('sys_graphql_resolver_mapping') const typeResolvers = descendants.query('sys_graphql_typeresolver') const resolverIds = new Set(resolvers.map((r) => r.getId().getValue())) const orphanedMappings = mappings.filter((m) => !resolverIds.has(resolverRefId(m))) if (orphanedMappings.length > 0) { const ids = orphanedMappings.map((m) => m.getId().getValue()).join(', ') logger.warn( `Schema ${record.getId().getValue()} has ${orphanedMappings.length} resolver mapping record(s) that reference non-existent resolvers (${ids}). The orphaned mappings remain as Record() calls while the rest of the schema is transformed as GraphQLApi(); clean up these mappings on the instance to include them in GraphQLApi().` ) } const resolverShapes = await Promise.all( resolvers.map(async (resolver) => { const script = await NowIncludeShape.fromRecord(resolver, resolver.get('script'), transform) const resolverId = resolver.getId().getValue() const paths = mappings .filter((m) => resolverRefId(m) === resolverId) .map((m) => m.get('path').asString().getValue()) return resolver.transform(({ $ }) => ({ $id: $.val(NowIdShape.from(resolver)), name: $, script: $.val(script), paths: $.val(paths), })) }) ) const typeResolverShapes = await Promise.all( typeResolvers.map(async (typeResolver) => { const script = await NowIncludeShape.fromRecord( typeResolver, typeResolver.get('script'), transform ) return typeResolver.transform(({ $ }) => ({ $id: $.val(NowIdShape.from(typeResolver)), typeName: $.from('type_name'), script: $.val(script), })) }) ) const schema = new NowIncludeShape({ source: record, path: `./${await transform.getUpdateName(record)}.graphql`, includedText: record.get('schema').toString().getValue(), }) const shape = new CallExpressionShape({ source: record, callee: 'GraphQLApi', args: [ record.transform(({ $ }) => ({ $id: $.val(NowIdShape.from(record)), name: $, applicationNamespace: $.from('application_namespace').def( applicationNamespaceFromScope(config.scope) ), namespace: $, schema: $.val(schema), resolvers: $.val(resolverShapes).def([]), typeResolvers: $.val(typeResolverShapes).def([]), active: $.toBoolean().def(true), enforceAcl: $.from('enforce_acl') .map((v) => splitAcls(v)) .def([]), requiresAuthentication: $.from('requires_authentication').toBoolean().def(true), requiresAclAuthorization: $.from('requires_acl_authorization').toBoolean().def(true), requiresSncInternalRole: $.from('requires_snc_internal_role').toBoolean().def(true), contextualAclMaxDepth: $.from('contextual_acl_max_depth') .map((value) => value.ifString()?.ifNotEmpty() ?? DEFAULT_CONTEXTUAL_ACL_MAX_DEPTH) .toNumber() .def(DEFAULT_CONTEXTUAL_ACL_MAX_DEPTH), })), ], }) const orphanedMappingIds = new Set(orphanedMappings.map((mapping) => mapping.getId().getValue())) const reclaimedRecords = mappings .filter( (mapping) => mapping.getCreator()?.getName() === RecordPlugin.getName() && !orphanedMappingIds.has(mapping.getId().getValue()) ) .map((mapping) => new DeletedShape({ source: mapping.getSource() })) if (orphanedMappings.length > 0) { return { success: 'partial', value: shape, unhandledRecords: orphanedMappings, reclaimedRecords, } } return { success: true, value: shape, reclaimedRecords } }, }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(callExpression, { factory, config, diagnostics }) { if (callExpression.getCallee() !== 'GraphQLApi') { return { success: false } } const arg = callExpression.getArgument(0).asObject() const resolvers = arg.get('resolvers')?.ifArray()?.getElements() ?? [] const typeResolvers = arg.get('typeResolvers')?.ifArray()?.getElements() ?? [] const resolverNames = resolvers.map((r) => r.asObject().get('name')) const resolverPaths = resolvers.flatMap( (r) => r.asObject().get('paths')?.ifArray()?.getElements() ?? [] ) const typeNames = typeResolvers.map((t) => t.asObject().get('typeName')) reportDuplicateValues( resolverNames, (name) => `Duplicate resolver name "${name}". Resolver names must be unique within a GraphQLApi.`, diagnostics ) reportDuplicateValues( resolverPaths, (path) => `Duplicate path "${path}". Each schema path can be served by only one resolver.`, diagnostics ) reportDuplicateValues( typeNames, (typeName) => `Duplicate type resolver "${typeName}". Each type can have only one type resolver.`, diagnostics ) const namespace = arg.get('namespace').asString() if (!GRAPHQL_NAME.test(namespace.getValue())) { diagnostics.error( namespace, `Invalid namespace "${namespace.getValue()}". A namespace must be a valid GraphQL name: a letter or underscore, followed by letters, digits, or underscores.` ) } const isGlobalScope = config.scope === 'global' const scopedApplicationNamespace = applicationNamespaceFromScope(config.scope) const requestedApplicationNamespace = arg.get('applicationNamespace')?.ifString()?.getValue() if ( !isGlobalScope && requestedApplicationNamespace && requestedApplicationNamespace !== scopedApplicationNamespace ) { diagnostics.hint( arg.get('applicationNamespace'), `A custom application namespace is only supported in global scope. Using '${scopedApplicationNamespace}', derived from scope '${config.scope}'.` ) } if (isGlobalScope && requestedApplicationNamespace) { const source = arg.get('applicationNamespace') if (!GRAPHQL_NAME.test(requestedApplicationNamespace)) { diagnostics.error( source, `Invalid application namespace "${requestedApplicationNamespace}". An application namespace must be a valid GraphQL name: a letter or underscore, followed by letters, digits, or underscores.` ) } else if (requestedApplicationNamespace.length > MAX_APPLICATION_NAMESPACE_LENGTH) { diagnostics.error( source, `Application namespace "${requestedApplicationNamespace}" is ${requestedApplicationNamespace.length} characters. The platform limits it to ${MAX_APPLICATION_NAMESPACE_LENGTH}.` ) } } const applicationNamespace = isGlobalScope && requestedApplicationNamespace ? requestedApplicationNamespace : scopedApplicationNamespace const schemaRecord = await factory.createRecord({ source: callExpression, table: 'sys_graphql_schema', explicitId: arg.get('$id'), properties: arg.transform(({ $ }) => ({ name: $, application_namespace: $.val(applicationNamespace), namespace: $, schema: $.toCdata(), active: $.def(true), enforce_acl: $.val(mergeAcls(arg.get('enforceAcl'))), requires_authentication: $.from('requiresAuthentication').def(true), requires_acl_authorization: $.from('requiresAclAuthorization').def(true), requires_snc_internal_role: $.from('requiresSncInternalRole').def(true), contextual_acl_max_depth: $.from('contextualAclMaxDepth').def(DEFAULT_CONTEXTUAL_ACL_MAX_DEPTH), })), }) const resolverRecords = await generateResolverRecords( resolvers, schemaRecord, factory, config, diagnostics ) const typeResolverRecords = await generateTypeResolverRecords( typeResolvers, schemaRecord, factory, config, diagnostics ) return { success: true, value: schemaRecord.with(...resolverRecords, ...typeResolverRecords), } }, }, ], })