diff --git a/graphql/server/src/middleware/__tests__/api.test.ts b/graphql/server/src/middleware/__tests__/api.test.ts new file mode 100644 index 000000000..3f00712c9 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/api.test.ts @@ -0,0 +1,117 @@ +jest.mock('pg-cache', () => ({ + getPgPool: jest.fn(), +})); + +jest.mock('@constructive-io/express-context', () => ({ + createDefaultRegistry: jest.fn(() => ({ + resolve: jest.fn().mockResolvedValue(undefined), + })), +})); + +import type { Request } from 'express'; +import type { Pool } from 'pg'; +import { svcCache } from '@pgpmjs/server-utils'; +import { getPgPool } from 'pg-cache'; + +import type { ApiOptions } from '../../types'; +import { getApiConfig, getSvcKey } from '../api'; + +const mockGetPgPool = getPgPool as jest.MockedFunction; + +const createRequest = (headers: Record): Request => { + const normalized = new Map( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + + return { + protocol: 'http', + originalUrl: '/graphql', + get: jest.fn((name: string) => normalized.get(name.toLowerCase())), + } as unknown as Request; +}; + +const createPrivateOptions = (): ApiOptions => ({ + pg: { + database: 'constructive', + }, + api: { + isPublic: false, + metaSchemas: ['metaschema_public'], + }, +} as unknown as ApiOptions); + +describe('api middleware routing priority', () => { + beforeEach(() => { + svcCache.clear(); + jest.clearAllMocks(); + }); + + afterEach(() => { + svcCache.clear(); + }); + + it('uses X-Api-Name before X-Schemata when building private service keys', () => { + const req = createRequest({ + host: 'admin.localhost', + 'X-Database-Id': 'db-123', + 'X-Api-Name': 'customer-api', + 'X-Schemata': 'services_public', + }); + + expect(getSvcKey(createPrivateOptions(), req)).toBe('api:db-123:customer-api'); + }); + + it('uses the same X-Api-Name priority when resolving and caching API config', async () => { + const query = jest.fn(async (_sql: string, params: unknown[]) => { + if (Array.isArray(params[0])) { + return { + rows: (params[0] as string[]).map((schemaName) => ({ + schema_name: schemaName, + })), + }; + } + + if (params[0] === 'db-123' && params[1] === 'customer-api') { + return { + rows: [{ + api_id: 'api-123', + database_id: 'db-123', + dbname: 'tenant_db', + role_name: 'api_role', + anon_role: 'api_anon', + is_public: false, + schemas: ['api_public'], + }], + }; + } + + return { rows: [] }; + }); + + mockGetPgPool.mockReturnValue({ query } as unknown as Pool); + + const req = createRequest({ + host: 'admin.localhost', + 'X-Database-Id': 'db-123', + 'X-Api-Name': 'customer-api', + 'X-Schemata': 'services_public', + }); + + const result = await getApiConfig(createPrivateOptions(), req); + + expect(req.svc_key).toBe('api:db-123:customer-api'); + expect(result).toMatchObject({ + apiId: 'api-123', + dbname: 'tenant_db', + anonRole: 'api_anon', + roleName: 'api_role', + schema: ['api_public'], + databaseId: 'db-123', + isPublic: false, + }); + expect(svcCache.get('api:db-123:customer-api')).toBe(result); + expect(query.mock.calls).toEqual(expect.arrayContaining([ + [expect.stringContaining('FROM services_public.apis'), ['db-123', 'customer-api', false]], + ])); + }); +}); diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 7d241e114..005be581f 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -120,12 +120,7 @@ interface ResolveContext { domain: string; subdomain: string | null; cacheKey: string; - headers: { - schemata?: string; - apiName?: string; - metaSchema?: string; - databaseId?: string; - }; + headers: RoutingHeaders; } type ResolutionMode = @@ -135,6 +130,15 @@ type ResolutionMode = | 'meta-schema-header' | 'domain-lookup'; +type PrivateHeaderMode = Exclude; + +interface RoutingHeaders { + schemata?: string; + apiName?: string; + metaSchema?: string; + databaseId?: string; +} + // ============================================================================= // Module Resolution (via loader registry) // ============================================================================= @@ -208,6 +212,20 @@ const isApiError = (result: ApiConfigResult): result is ApiError => const parseCommaSeparatedHeader = (value: string): string[] => value.split(',').map((s) => s.trim()).filter(Boolean); +const getPrivateHeaderMode = (headers: RoutingHeaders): PrivateHeaderMode | null => { + if (headers.apiName) return 'api-name-header'; + if (headers.schemata) return 'schemata-header'; + if (headers.metaSchema) return 'meta-schema-header'; + return null; +}; + +const getRoutingHeaders = (req: Request): RoutingHeaders => ({ + schemata: req.get('X-Schemata'), + apiName: req.get('X-Api-Name'), + metaSchema: req.get('X-Meta-Schema'), + databaseId: req.get('X-Database-Id'), +}); + const getUrlDomains = (req: Request): { domain: string; subdomains: string[] } => { const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`; const parsed = parseUrl(fullUrl); @@ -227,14 +245,16 @@ export const getSvcKey = (opts: ApiOptions, req: Request): string => { const baseKey = subdomains.filter((n) => n !== 'www').concat(domain).join('.'); if (opts.api?.isPublic === false) { - if (req.get('X-Api-Name')) { - return `api:${req.get('X-Database-Id')}:${req.get('X-Api-Name')}`; + const headers = getRoutingHeaders(req); + const mode = getPrivateHeaderMode(headers); + if (mode === 'api-name-header') { + return `api:${headers.databaseId}:${headers.apiName}`; } - if (req.get('X-Schemata')) { - return `schemata:${req.get('X-Database-Id')}:${req.get('X-Schemata')}`; + if (mode === 'schemata-header') { + return `schemata:${headers.databaseId}:${headers.schemata}`; } - if (req.get('X-Meta-Schema')) { - return `metaschema:api:${req.get('X-Database-Id')}`; + if (mode === 'meta-schema-header') { + return `metaschema:api:${headers.databaseId}`; } } return baseKey; @@ -319,9 +339,7 @@ const determineMode = (ctx: ResolveContext): ResolutionMode => { if (opts.api?.enableServicesApi === false) return 'services-disabled'; if (opts.api?.isPublic === false) { - if (headers.schemata) return 'schemata-header'; - if (headers.apiName) return 'api-name-header'; - if (headers.metaSchema) return 'meta-schema-header'; + return getPrivateHeaderMode(headers) ?? 'domain-lookup'; } return 'domain-lookup'; }; @@ -479,12 +497,7 @@ export const getApiConfig = async ( domain, subdomain, cacheKey, - headers: { - schemata: req.get('X-Schemata'), - apiName: req.get('X-Api-Name'), - metaSchema: req.get('X-Meta-Schema'), - databaseId: req.get('X-Database-Id'), - }, + headers: getRoutingHeaders(req), }; // Validate schemas upfront for modes that need them