@constructive-io/graphql-server 3.1.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/errors/404-message.js +1 -1
  2. package/errors/api-errors.d.ts +200 -0
  3. package/errors/api-errors.js +276 -0
  4. package/esm/errors/404-message.js +1 -1
  5. package/esm/errors/api-errors.js +261 -0
  6. package/esm/index.js +2 -0
  7. package/esm/middleware/api.js +355 -277
  8. package/esm/middleware/auth.js +25 -7
  9. package/esm/middleware/error-handler.js +86 -0
  10. package/esm/middleware/favicon.js +12 -0
  11. package/esm/middleware/graphile.js +149 -64
  12. package/esm/options.js +232 -0
  13. package/esm/schema.js +24 -11
  14. package/esm/server.js +41 -5
  15. package/index.d.ts +1 -0
  16. package/index.js +2 -0
  17. package/middleware/api.d.ts +3 -15
  18. package/middleware/api.js +359 -283
  19. package/middleware/auth.js +25 -7
  20. package/middleware/error-handler.d.ts +4 -0
  21. package/middleware/error-handler.js +94 -0
  22. package/middleware/favicon.d.ts +2 -0
  23. package/middleware/favicon.js +16 -0
  24. package/middleware/graphile.d.ts +14 -0
  25. package/middleware/graphile.js +149 -64
  26. package/options.d.ts +131 -0
  27. package/options.js +244 -0
  28. package/package.json +23 -24
  29. package/schema.d.ts +2 -2
  30. package/schema.js +23 -10
  31. package/server.d.ts +24 -2
  32. package/server.js +39 -3
  33. package/codegen/orm/client.d.ts +0 -55
  34. package/codegen/orm/client.js +0 -75
  35. package/codegen/orm/index.d.ts +0 -36
  36. package/codegen/orm/index.js +0 -59
  37. package/codegen/orm/input-types.d.ts +0 -20140
  38. package/codegen/orm/input-types.js +0 -2
  39. package/codegen/orm/models/api.d.ts +0 -42
  40. package/codegen/orm/models/api.js +0 -76
  41. package/codegen/orm/models/domain.d.ts +0 -42
  42. package/codegen/orm/models/domain.js +0 -76
  43. package/codegen/orm/models/index.d.ts +0 -7
  44. package/codegen/orm/models/index.js +0 -12
  45. package/codegen/orm/mutation/index.d.ts +0 -7
  46. package/codegen/orm/mutation/index.js +0 -7
  47. package/codegen/orm/query/index.d.ts +0 -20
  48. package/codegen/orm/query/index.js +0 -24
  49. package/codegen/orm/query-builder.d.ts +0 -81
  50. package/codegen/orm/query-builder.js +0 -496
  51. package/codegen/orm/select-types.d.ts +0 -83
  52. package/codegen/orm/select-types.js +0 -7
  53. package/codegen/orm/types.d.ts +0 -6
  54. package/codegen/orm/types.js +0 -23
  55. package/esm/codegen/orm/client.js +0 -70
  56. package/esm/codegen/orm/index.js +0 -39
  57. package/esm/codegen/orm/input-types.js +0 -1
  58. package/esm/codegen/orm/models/api.js +0 -72
  59. package/esm/codegen/orm/models/domain.js +0 -72
  60. package/esm/codegen/orm/models/index.js +0 -7
  61. package/esm/codegen/orm/mutation/index.js +0 -4
  62. package/esm/codegen/orm/query/index.js +0 -21
  63. package/esm/codegen/orm/query-builder.js +0 -452
  64. package/esm/codegen/orm/select-types.js +0 -6
  65. package/esm/codegen/orm/types.js +0 -7
  66. package/esm/middleware/gql.js +0 -116
  67. package/esm/plugins/PublicKeySignature.js +0 -114
  68. package/esm/scripts/codegen-schema.js +0 -71
  69. package/esm/scripts/create-bucket.js +0 -40
  70. package/middleware/gql.d.ts +0 -164
  71. package/middleware/gql.js +0 -121
  72. package/plugins/PublicKeySignature.d.ts +0 -11
  73. package/plugins/PublicKeySignature.js +0 -121
  74. package/scripts/codegen-schema.d.ts +0 -1
  75. package/scripts/codegen-schema.js +0 -76
  76. package/scripts/create-bucket.d.ts +0 -1
  77. package/scripts/create-bucket.js +0 -42
@@ -2,31 +2,88 @@ import { getNodeEnv } from '@constructive-io/graphql-env';
2
2
  import { Logger } from '@pgpmjs/logger';
3
3
  import { svcCache } from '@pgpmjs/server-utils';
4
4
  import { parseUrl } from '@constructive-io/url-domains';
5
- import { getSchema, GraphileQuery } from 'graphile-query';
6
- import { getGraphileSettings } from 'graphile-settings';
7
5
  import { getPgPool } from 'pg-cache';
8
6
  import errorPage50x from '../errors/50x';
9
7
  import errorPage404Message from '../errors/404-message';
10
- /**
11
- * Normalizes API records into ApiStructure
12
- */
13
- import { apiListSelect, apiSelect, connectionFirst, createGraphileOrm, domainSelect, normalizeApiRecord, } from './gql';
14
- import './types'; // for Request type
15
- export { normalizeApiRecord } from './gql';
8
+ import './types';
16
9
  const log = new Logger('api');
17
10
  const isDev = () => getNodeEnv() === 'development';
18
- const isApiError = (svc) => !!svc && typeof svc.errorHtml === 'string';
19
- const getPortFromRequest = (req) => {
20
- const host = req.headers.host;
21
- if (!host)
22
- return null;
23
- const parts = host.split(':');
24
- return parts.length === 2 ? `:${parts[1]}` : null;
25
- };
26
- const parseCommaSeparatedHeader = (value) => value
27
- .split(',')
28
- .map((item) => item.trim())
29
- .filter((item) => item.length > 0);
11
+ // =============================================================================
12
+ // SQL Queries
13
+ // =============================================================================
14
+ const DOMAIN_LOOKUP_SQL = `
15
+ SELECT
16
+ a.id as api_id,
17
+ a.database_id,
18
+ a.dbname,
19
+ a.role_name,
20
+ a.anon_role,
21
+ a.is_public,
22
+ COALESCE(array_agg(s.schema_name) FILTER (WHERE s.schema_name IS NOT NULL), '{}') as schemas
23
+ FROM services_public.domains d
24
+ JOIN services_public.apis a ON d.api_id = a.id
25
+ LEFT JOIN services_public.api_schemas aps ON a.id = aps.api_id
26
+ LEFT JOIN metaschema_public.schema s ON aps.schema_id = s.id
27
+ WHERE d.domain = $1
28
+ AND (($2::text IS NULL AND d.subdomain IS NULL) OR d.subdomain = $2)
29
+ AND a.is_public = $3
30
+ GROUP BY a.id, a.database_id, a.dbname, a.role_name, a.anon_role, a.is_public
31
+ LIMIT 1
32
+ `;
33
+ const API_NAME_LOOKUP_SQL = `
34
+ SELECT
35
+ a.id as api_id,
36
+ a.database_id,
37
+ a.dbname,
38
+ a.role_name,
39
+ a.anon_role,
40
+ a.is_public,
41
+ COALESCE(array_agg(s.schema_name) FILTER (WHERE s.schema_name IS NOT NULL), '{}') as schemas
42
+ FROM services_public.apis a
43
+ LEFT JOIN services_public.api_schemas aps ON a.id = aps.api_id
44
+ LEFT JOIN metaschema_public.schema s ON aps.schema_id = s.id
45
+ WHERE a.database_id = $1
46
+ AND a.name = $2
47
+ AND a.is_public = $3
48
+ GROUP BY a.id, a.database_id, a.dbname, a.role_name, a.anon_role, a.is_public
49
+ LIMIT 1
50
+ `;
51
+ const API_LIST_SQL = `
52
+ SELECT
53
+ a.id,
54
+ a.database_id,
55
+ a.name,
56
+ a.dbname,
57
+ a.role_name,
58
+ a.anon_role,
59
+ a.is_public,
60
+ COALESCE(
61
+ json_agg(
62
+ json_build_object('domain', d.domain, 'subdomain', d.subdomain)
63
+ ) FILTER (WHERE d.domain IS NOT NULL),
64
+ '[]'
65
+ ) as domains
66
+ FROM services_public.apis a
67
+ LEFT JOIN services_public.domains d ON a.id = d.api_id
68
+ WHERE a.is_public = $1
69
+ GROUP BY a.id, a.database_id, a.name, a.dbname, a.role_name, a.anon_role, a.is_public
70
+ LIMIT 100
71
+ `;
72
+ const RLS_MODULE_SQL = `
73
+ SELECT
74
+ rm.authenticate,
75
+ rm.authenticate_strict,
76
+ ps.schema_name as private_schema_name
77
+ FROM metaschema_modules_public.rls_module rm
78
+ LEFT JOIN metaschema_public.schema ps ON rm.private_schema_id = ps.id
79
+ WHERE rm.api_id = $1
80
+ LIMIT 1
81
+ `;
82
+ // =============================================================================
83
+ // Helpers
84
+ // =============================================================================
85
+ const isApiError = (result) => !!result && typeof result.errorHtml === 'string';
86
+ const parseCommaSeparatedHeader = (value) => value.split(',').map((s) => s.trim()).filter(Boolean);
30
87
  const getUrlDomains = (req) => {
31
88
  const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
32
89
  const parsed = parseUrl(fullUrl);
@@ -35,292 +92,313 @@ const getUrlDomains = (req) => {
35
92
  subdomains: parsed.subdomains ?? [],
36
93
  };
37
94
  };
38
- export const getSubdomain = (reqDomains) => {
39
- const names = reqDomains.filter((name) => !['www'].includes(name));
40
- return !names.length ? null : names.join('.');
95
+ export const getSubdomain = (subdomains) => {
96
+ const filtered = subdomains.filter((name) => name !== 'www');
97
+ return filtered.length ? filtered.join('.') : null;
41
98
  };
42
- export const createApiMiddleware = (opts) => {
43
- return async (req, res, next) => {
44
- // Log incoming request details at debug level to avoid excessive info logs in production
45
- log.debug(`[api-middleware] Request: ${req.method} ${req.path}`);
46
- log.debug(`[api-middleware] Headers: X-Api-Name=${req.get('X-Api-Name')}, X-Database-Id=${req.get('X-Database-Id')}, X-Meta-Schema=${req.get('X-Meta-Schema')}, Host=${req.get('Host')}`);
47
- if (opts.api?.enableServicesApi === false) {
48
- const schemas = opts.api.exposedSchemas ?? [];
49
- const anonRole = opts.api.anonRole ?? '';
50
- const roleName = opts.api.roleName ?? '';
51
- const databaseId = opts.api.defaultDatabaseId;
52
- const api = {
53
- dbname: opts.pg?.database ?? '',
54
- anonRole,
55
- roleName,
56
- schema: schemas,
57
- apiModules: [],
58
- domains: [],
59
- databaseId,
60
- isPublic: false,
61
- };
62
- req.api = api;
63
- req.databaseId = databaseId;
64
- req.svc_key = 'meta-api-off';
65
- return next();
99
+ export const getSvcKey = (opts, req) => {
100
+ const { domain, subdomains } = getUrlDomains(req);
101
+ const baseKey = subdomains.filter((n) => n !== 'www').concat(domain).join('.');
102
+ if (opts.api?.isPublic === false) {
103
+ if (req.get('X-Api-Name')) {
104
+ return `api:${req.get('X-Database-Id')}:${req.get('X-Api-Name')}`;
66
105
  }
67
- try {
68
- const apiConfig = await getApiConfig(opts, req);
69
- if (isApiError(apiConfig)) {
70
- res
71
- .status(404)
72
- .send(errorPage404Message('API not found', apiConfig.errorHtml));
73
- return;
74
- }
75
- else if (!apiConfig) {
76
- res
77
- .status(404)
78
- .send(errorPage404Message('API service not found for the given domain/subdomain.'));
79
- return;
80
- }
81
- req.api = apiConfig;
82
- req.databaseId = apiConfig.databaseId;
83
- if (isDev())
84
- log.debug(`Resolved API: db=${apiConfig.dbname}, schemas=[${apiConfig.schema?.join(', ')}]`);
85
- next();
106
+ if (req.get('X-Schemata')) {
107
+ return `schemata:${req.get('X-Database-Id')}:${req.get('X-Schemata')}`;
86
108
  }
87
- catch (error) {
88
- const err = error;
89
- if (err.code === 'NO_VALID_SCHEMAS') {
90
- res.status(404).send(errorPage404Message(err.message));
91
- }
92
- else if (err.message?.match(/does not exist/)) {
93
- res
94
- .status(404)
95
- .send(errorPage404Message("The resource you're looking for does not exist."));
96
- }
97
- else {
98
- log.error('API middleware error:', err);
99
- res.status(500).send(errorPage50x);
100
- }
109
+ if (req.get('X-Meta-Schema')) {
110
+ return `metaschema:api:${req.get('X-Database-Id')}`;
101
111
  }
112
+ }
113
+ return baseKey;
114
+ };
115
+ const toRlsModule = (row) => {
116
+ if (!row || !row.private_schema_name)
117
+ return undefined;
118
+ return {
119
+ authenticate: row.authenticate ?? undefined,
120
+ authenticateStrict: row.authenticate_strict ?? undefined,
121
+ privateSchema: {
122
+ schemaName: row.private_schema_name,
123
+ },
102
124
  };
103
125
  };
104
- const createAdminApiStructure = ({ opts, schemata, key, databaseId, }) => {
105
- const api = {
126
+ const toApiStructure = (row, opts, rlsModuleRow) => ({
127
+ dbname: row.dbname || opts.pg?.database || '',
128
+ anonRole: row.anon_role || 'anon',
129
+ roleName: row.role_name || 'authenticated',
130
+ schema: row.schemas || [],
131
+ apiModules: [],
132
+ rlsModule: toRlsModule(rlsModuleRow ?? null),
133
+ domains: [],
134
+ databaseId: row.database_id,
135
+ isPublic: row.is_public,
136
+ });
137
+ const createAdminStructure = (opts, schemas, databaseId) => ({
138
+ dbname: opts.pg?.database ?? '',
139
+ anonRole: 'administrator',
140
+ roleName: 'administrator',
141
+ schema: schemas,
142
+ apiModules: [],
143
+ domains: [],
144
+ databaseId,
145
+ isPublic: false,
146
+ });
147
+ // =============================================================================
148
+ // Database Queries
149
+ // =============================================================================
150
+ const validateSchemata = async (pool, schemas) => {
151
+ const result = await pool.query(`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ANY($1::text[])`, [schemas]);
152
+ return result.rows.map((row) => row.schema_name);
153
+ };
154
+ const queryByDomain = async (pool, domain, subdomain, isPublic) => {
155
+ const result = await pool.query(DOMAIN_LOOKUP_SQL, [domain, subdomain, isPublic]);
156
+ return result.rows[0] ?? null;
157
+ };
158
+ const queryByApiName = async (pool, databaseId, name, isPublic) => {
159
+ const result = await pool.query(API_NAME_LOOKUP_SQL, [databaseId, name, isPublic]);
160
+ return result.rows[0] ?? null;
161
+ };
162
+ const queryApiList = async (pool, isPublic) => {
163
+ const result = await pool.query(API_LIST_SQL, [isPublic]);
164
+ return result.rows;
165
+ };
166
+ const queryRlsModule = async (pool, apiId) => {
167
+ const result = await pool.query(RLS_MODULE_SQL, [apiId]);
168
+ return result.rows[0] ?? null;
169
+ };
170
+ // =============================================================================
171
+ // Resolution Logic
172
+ // =============================================================================
173
+ const determineMode = (ctx) => {
174
+ const { opts, headers } = ctx;
175
+ if (opts.api?.enableServicesApi === false)
176
+ return 'services-disabled';
177
+ if (opts.api?.isPublic === false) {
178
+ if (headers.schemata)
179
+ return 'schemata-header';
180
+ if (headers.apiName)
181
+ return 'api-name-header';
182
+ if (headers.metaSchema)
183
+ return 'meta-schema-header';
184
+ }
185
+ return 'domain-lookup';
186
+ };
187
+ const resolveServicesDisabled = (ctx) => {
188
+ const { opts } = ctx;
189
+ return {
106
190
  dbname: opts.pg?.database ?? '',
107
- anonRole: 'administrator',
108
- roleName: 'administrator',
109
- schema: schemata,
191
+ anonRole: opts.api?.anonRole ?? '',
192
+ roleName: opts.api?.roleName ?? '',
193
+ schema: opts.api?.exposedSchemas ?? [],
110
194
  apiModules: [],
111
195
  domains: [],
112
- databaseId,
196
+ databaseId: opts.api?.defaultDatabaseId,
113
197
  isPublic: false,
114
198
  };
115
- svcCache.set(key, api);
116
- return api;
117
199
  };
118
- const queryServiceByDomainAndSubdomain = async ({ opts, key, domainModel, domain, subdomain, }) => {
119
- const where = {
120
- domain: { equalTo: domain },
121
- subdomain: subdomain === null || subdomain === undefined
122
- ? { isNull: true }
123
- : { equalTo: subdomain },
124
- };
125
- const result = await domainModel
126
- .findFirst({ select: domainSelect, where })
127
- .execute();
128
- if (!result.ok) {
129
- log.error('GraphQL query errors:', result.errors);
130
- return null;
200
+ const resolveSchemataHeader = async (ctx, validatedSchemas) => {
201
+ const { opts, headers } = ctx;
202
+ const headerSchemas = parseCommaSeparatedHeader(headers.schemata);
203
+ const validSet = new Set(validatedSchemas);
204
+ const validHeaderSchemas = headerSchemas.filter((s) => validSet.has(s));
205
+ if (validHeaderSchemas.length === 0) {
206
+ return { errorHtml: 'No valid schemas found for the supplied X-Schemata header.' };
131
207
  }
132
- const domainRecord = result.data.domains.nodes[0];
133
- const api = domainRecord?.api;
134
- const apiPublic = opts.api?.isPublic;
135
- if (!api || api.isPublic !== apiPublic)
136
- return null;
137
- const apiStructure = normalizeApiRecord(api);
138
- svcCache.set(key, apiStructure);
139
- return apiStructure;
208
+ return createAdminStructure(opts, validHeaderSchemas, headers.databaseId);
140
209
  };
141
- export const queryServiceByApiName = async ({ opts, key, queryOps, databaseId, name, }) => {
142
- if (!databaseId)
210
+ const resolveApiNameHeader = async (ctx) => {
211
+ const { opts, pool, headers } = ctx;
212
+ if (!headers.databaseId)
143
213
  return null;
144
- const result = await queryOps
145
- .apiByDatabaseIdAndName({ databaseId, name }, { select: apiSelect })
146
- .execute();
147
- if (!result.ok) {
148
- log.error('GraphQL query errors:', result.errors);
214
+ const isPublic = opts.api?.isPublic ?? false;
215
+ const row = await queryByApiName(pool, headers.databaseId, headers.apiName, isPublic);
216
+ if (!row) {
217
+ log.debug(`[api-name-lookup] No API found for databaseId=${headers.databaseId} name=${headers.apiName}`);
149
218
  return null;
150
219
  }
151
- const api = result.data.apiByDatabaseIdAndName;
152
- const apiPublic = opts.api?.isPublic;
153
- if (api && api.isPublic === apiPublic) {
154
- const apiStructure = normalizeApiRecord(api);
155
- svcCache.set(key, apiStructure);
156
- return apiStructure;
157
- }
158
- return null;
220
+ const rlsModule = await queryRlsModule(pool, row.api_id);
221
+ log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}`);
222
+ return toApiStructure(row, opts, rlsModule);
159
223
  };
160
- export const getSvcKey = (opts, req) => {
161
- const { domain, subdomains } = getUrlDomains(req);
162
- const key = subdomains
163
- .filter((name) => !['www'].includes(name))
164
- .concat(domain)
165
- .join('.');
166
- const apiPublic = opts.api?.isPublic;
167
- if (apiPublic === false) {
168
- if (req.get('X-Api-Name')) {
169
- return 'api:' + req.get('X-Database-Id') + ':' + req.get('X-Api-Name');
170
- }
171
- if (req.get('X-Schemata')) {
172
- return ('schemata:' + req.get('X-Database-Id') + ':' + req.get('X-Schemata'));
173
- }
174
- if (req.get('X-Meta-Schema')) {
175
- return 'metaschema:api:' + req.get('X-Database-Id');
176
- }
224
+ const resolveMetaSchemaHeader = (ctx, validatedSchemas) => {
225
+ return createAdminStructure(ctx.opts, validatedSchemas, ctx.headers.databaseId);
226
+ };
227
+ const resolveDomainLookup = async (ctx) => {
228
+ const { opts, pool, domain, subdomain } = ctx;
229
+ const isPublic = opts.api?.isPublic ?? false;
230
+ log.debug(`[domain-lookup] domain=${domain} subdomain=${subdomain} isPublic=${isPublic}`);
231
+ const row = await queryByDomain(pool, domain, subdomain, isPublic);
232
+ if (!row) {
233
+ log.debug(`[domain-lookup] No API found for domain=${domain} subdomain=${subdomain}`);
234
+ return null;
177
235
  }
178
- return key;
236
+ const rlsModule = await queryRlsModule(pool, row.api_id);
237
+ log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}`);
238
+ return toApiStructure(row, opts, rlsModule);
179
239
  };
180
- const validateSchemata = async (pool, schemata) => {
181
- const result = await pool.query(`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ANY($1::text[])`, [schemata]);
182
- return result.rows.map((row) => row.schema_name);
240
+ const buildDevFallbackError = async (ctx, req) => {
241
+ if (getNodeEnv() !== 'development')
242
+ return null;
243
+ const isPublic = ctx.opts.api?.isPublic ?? false;
244
+ const apis = await queryApiList(ctx.pool, isPublic);
245
+ if (!apis.length)
246
+ return null;
247
+ const host = req.get('host') || '';
248
+ const portMatch = host.match(/:(\d+)$/);
249
+ const port = portMatch ? portMatch[1] : '';
250
+ const apiCards = apis.map((api) => {
251
+ const domains = api.domains.length
252
+ ? api.domains.map((d) => {
253
+ const hostname = d.subdomain ? `${d.subdomain}.${d.domain}` : d.domain;
254
+ const url = port ? `http://${hostname}:${port}/graphiql` : `http://${hostname}/graphiql`;
255
+ return `<a href="${url}" style="color:#01A1FF;text-decoration:none;font-weight:500" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">${hostname}</a>`;
256
+ }).join('<span style="color:#D4DCEA;margin:0 4px">·</span>')
257
+ : '<span style="color:#8E9398;font-style:italic;font-size:11px">no domains</span>';
258
+ const badge = api.is_public
259
+ ? '<span style="color:#01A1FF;font-size:10px;font-weight:500">public</span>'
260
+ : '<span style="color:#8E9398;font-size:10px">private</span>';
261
+ return `
262
+ <div style="background:#fff;border-radius:8px;padding:10px 14px;margin-bottom:6px;box-shadow:0 1px 3px rgba(0,0,0,0.04);border:1px solid #E8ECF0;display:flex;align-items:center;gap:12px;transition:background 0.15s" onmouseover="this.style.background='#FAFBFC'" onmouseout="this.style.background='#fff'">
263
+ <div style="flex:1;min-width:0;display:flex;align-items:center;gap:8px;font-size:13px">
264
+ <span style="font-weight:600;color:#232323;white-space:nowrap">${api.name}</span>
265
+ <span style="color:#D4DCEA">→</span>
266
+ ${domains}
267
+ </div>
268
+ <div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
269
+ <span style="color:#8E9398;font-size:11px;font-family:'SF Mono',Monaco,monospace">${api.dbname}</span>
270
+ ${badge}
271
+ </div>
272
+ </div>`;
273
+ }).join('');
274
+ return {
275
+ errorHtml: `
276
+ <div style="text-align:left;max-width:600px;margin:0 auto">
277
+ <p style="color:#8E9398;font-size:11px;margin-bottom:10px;font-weight:500;text-transform:uppercase;letter-spacing:0.5px">Available APIs</p>
278
+ ${apiCards}
279
+ </div>`,
280
+ };
183
281
  };
282
+ // =============================================================================
283
+ // Main Resolution Function
284
+ // =============================================================================
184
285
  export const getApiConfig = async (opts, req) => {
185
- const rootPgPool = getPgPool(opts.pg);
286
+ const pool = getPgPool(opts.pg);
186
287
  const { domain, subdomains } = getUrlDomains(req);
187
288
  const subdomain = getSubdomain(subdomains);
188
- const key = getSvcKey(opts, req);
189
- req.svc_key = key;
190
- let apiConfig;
191
- if (svcCache.has(key)) {
192
- if (isDev())
193
- log.debug(`Cache HIT for key=${key}`);
194
- apiConfig = svcCache.get(key);
289
+ const cacheKey = getSvcKey(opts, req);
290
+ req.svc_key = cacheKey;
291
+ // Check cache first
292
+ if (svcCache.has(cacheKey)) {
293
+ log.debug(`Cache HIT for key=${cacheKey}`);
294
+ return svcCache.get(cacheKey);
195
295
  }
196
- else {
197
- if (isDev())
198
- log.debug(`Cache MISS for key=${key}, looking up API`);
199
- const apiOpts = opts.api || {};
200
- const apiPublic = apiOpts.isPublic;
201
- const schemataHeader = req.get('X-Schemata');
202
- const apiNameHeader = req.get('X-Api-Name');
203
- const metaSchemaHeader = req.get('X-Meta-Schema');
204
- const databaseIdHeader = req.get('X-Database-Id');
205
- const headerSchemata = schemataHeader
206
- ? parseCommaSeparatedHeader(schemataHeader)
207
- : [];
208
- const candidateSchemata = apiPublic === false && headerSchemata.length
209
- ? Array.from(new Set([...(apiOpts.metaSchemas || []), ...headerSchemata]))
210
- : apiOpts.metaSchemas || [];
211
- const validatedSchemata = await validateSchemata(rootPgPool, candidateSchemata);
212
- if (validatedSchemata.length === 0) {
213
- const schemaSource = headerSchemata.length
214
- ? headerSchemata
215
- : apiOpts.metaSchemas || [];
216
- const label = headerSchemata.length ? 'X-Schemata' : 'metaSchemas';
217
- const message = `No valid schemas found. Configured ${label}: [${schemaSource.join(', ')}]`;
218
- if (isDev())
219
- log.debug(message);
220
- const error = new Error(message);
221
- error.code = 'NO_VALID_SCHEMAS';
222
- throw error;
223
- }
224
- const validSchemaSet = new Set(validatedSchemata);
225
- const validatedHeaderSchemata = headerSchemata.filter((schemaName) => validSchemaSet.has(schemaName));
226
- const settings = getGraphileSettings({
227
- graphile: {
228
- schema: validatedSchemata,
229
- },
230
- });
231
- const graphileSettings = {
232
- ...settings,
233
- schema: validatedSchemata,
234
- };
235
- const schema = await getSchema(rootPgPool, graphileSettings);
236
- const graphileClient = new GraphileQuery({
237
- schema,
238
- pool: rootPgPool,
239
- settings: graphileSettings,
240
- });
241
- const orm = createGraphileOrm(graphileClient);
242
- if (apiPublic === false) {
243
- if (schemataHeader) {
244
- if (validatedHeaderSchemata.length === 0) {
245
- return {
246
- errorHtml: 'No valid schemas found for the supplied X-Schemata header.',
247
- };
248
- }
249
- apiConfig = createAdminApiStructure({
250
- opts,
251
- schemata: validatedHeaderSchemata,
252
- key,
253
- databaseId: databaseIdHeader,
254
- });
255
- }
256
- else if (apiNameHeader) {
257
- apiConfig = await queryServiceByApiName({
258
- opts,
259
- key,
260
- queryOps: orm.query,
261
- name: apiNameHeader,
262
- databaseId: databaseIdHeader,
263
- });
264
- }
265
- else if (metaSchemaHeader) {
266
- apiConfig = createAdminApiStructure({
267
- opts,
268
- schemata: validatedSchemata,
269
- key,
270
- databaseId: databaseIdHeader,
271
- });
272
- }
273
- else {
274
- apiConfig = await queryServiceByDomainAndSubdomain({
275
- opts,
276
- key,
277
- domainModel: orm.domain,
278
- domain,
279
- subdomain,
280
- });
296
+ log.debug(`Cache MISS for key=${cacheKey}, resolving API`);
297
+ const ctx = {
298
+ opts,
299
+ pool,
300
+ domain,
301
+ subdomain,
302
+ cacheKey,
303
+ headers: {
304
+ schemata: req.get('X-Schemata'),
305
+ apiName: req.get('X-Api-Name'),
306
+ metaSchema: req.get('X-Meta-Schema'),
307
+ databaseId: req.get('X-Database-Id'),
308
+ },
309
+ };
310
+ // Validate schemas upfront for modes that need them
311
+ const apiOpts = opts.api || {};
312
+ const headerSchemas = ctx.headers.schemata ? parseCommaSeparatedHeader(ctx.headers.schemata) : [];
313
+ const candidateSchemas = apiOpts.isPublic === false && headerSchemas.length
314
+ ? [...new Set([...(apiOpts.metaSchemas || []), ...headerSchemas])]
315
+ : apiOpts.metaSchemas || [];
316
+ const validatedSchemas = await validateSchemata(pool, candidateSchemas);
317
+ if (validatedSchemas.length === 0) {
318
+ const source = headerSchemas.length ? headerSchemas : apiOpts.metaSchemas || [];
319
+ const label = headerSchemas.length ? 'X-Schemata' : 'metaSchemas';
320
+ const error = new Error(`No valid schemas found. Configured ${label}: [${source.join(', ')}]`);
321
+ error.code = 'NO_VALID_SCHEMAS';
322
+ throw error;
323
+ }
324
+ // Route to appropriate resolver based on mode
325
+ const mode = determineMode(ctx);
326
+ let result;
327
+ switch (mode) {
328
+ case 'services-disabled':
329
+ result = resolveServicesDisabled(ctx);
330
+ break;
331
+ case 'schemata-header':
332
+ result = await resolveSchemataHeader(ctx, validatedSchemas);
333
+ break;
334
+ case 'api-name-header':
335
+ result = await resolveApiNameHeader(ctx);
336
+ break;
337
+ case 'meta-schema-header':
338
+ result = resolveMetaSchemaHeader(ctx, validatedSchemas);
339
+ break;
340
+ case 'domain-lookup':
341
+ result = await resolveDomainLookup(ctx);
342
+ if (!result && apiOpts.isPublic) {
343
+ const fallback = await buildDevFallbackError(ctx, req);
344
+ if (fallback)
345
+ return fallback;
281
346
  }
282
- }
283
- else {
284
- apiConfig = await queryServiceByDomainAndSubdomain({
347
+ break;
348
+ }
349
+ // Cache successful results
350
+ if (result && !isApiError(result)) {
351
+ svcCache.set(cacheKey, result);
352
+ }
353
+ return result;
354
+ };
355
+ // =============================================================================
356
+ // Express Middleware
357
+ // =============================================================================
358
+ export const createApiMiddleware = (opts) => {
359
+ return async (req, res, next) => {
360
+ log.debug(`[api-middleware] ${req.method} ${req.path}`);
361
+ // Fast path: services disabled
362
+ if (opts.api?.enableServicesApi === false) {
363
+ req.api = resolveServicesDisabled({
285
364
  opts,
286
- key,
287
- domainModel: orm.domain,
288
- domain,
289
- subdomain,
365
+ pool: null,
366
+ domain: '',
367
+ subdomain: null,
368
+ cacheKey: 'meta-api-off',
369
+ headers: {},
290
370
  });
371
+ req.databaseId = req.api.databaseId;
372
+ req.svc_key = 'meta-api-off';
373
+ return next();
374
+ }
375
+ try {
376
+ const apiConfig = await getApiConfig(opts, req);
377
+ if (isApiError(apiConfig)) {
378
+ res.status(404).send(errorPage404Message('API not found', apiConfig.errorHtml));
379
+ return;
380
+ }
291
381
  if (!apiConfig) {
292
- // IMPORTANT NOTE: ONLY DO THIS IN DEV MODE
293
- if (getNodeEnv() === 'development') {
294
- const fallbackResult = await orm.api
295
- .findMany({ select: apiListSelect, first: connectionFirst })
296
- .execute();
297
- if (fallbackResult.ok && fallbackResult.data.apis.nodes.length) {
298
- const port = getPortFromRequest(req);
299
- const allDomains = fallbackResult.data.apis.nodes.flatMap((api) => api.domains.nodes.map((d) => ({
300
- domain: d.domain,
301
- subdomain: d.subdomain,
302
- href: d.subdomain
303
- ? `http://${d.subdomain}.${d.domain}${port}/graphiql`
304
- : `http://${d.domain}${port}/graphiql`,
305
- })));
306
- const linksHtml = allDomains.length
307
- ? `<ul class="mt-4 pl-5 list-disc space-y-1">` +
308
- allDomains
309
- .map((domainLink) => `<li><a href="${domainLink.href}" class="text-brand hover:underline">${domainLink.href}</a></li>`)
310
- .join('') +
311
- `</ul>`
312
- : `<p class="text-gray-600">No APIs are currently registered for this database.</p>`;
313
- const errorHtml = `
314
- <p class="text-sm text-gray-700">Try some of these:</p>
315
- <div class="mt-4">
316
- ${linksHtml}
317
- </div>
318
- `.trim();
319
- return { errorHtml };
320
- }
321
- }
382
+ res.status(404).send(errorPage404Message('API service not found for the given domain/subdomain.'));
383
+ return;
384
+ }
385
+ req.api = apiConfig;
386
+ req.databaseId = apiConfig.databaseId;
387
+ log.debug(`Resolved API: db=${apiConfig.dbname}, schemas=[${apiConfig.schema?.join(', ')}]`);
388
+ next();
389
+ }
390
+ catch (error) {
391
+ const err = error;
392
+ if (err.code === 'NO_VALID_SCHEMAS') {
393
+ res.status(404).send(errorPage404Message(err.message));
394
+ return;
395
+ }
396
+ if (err.message?.includes('does not exist')) {
397
+ res.status(404).send(errorPage404Message("The resource you're looking for does not exist."));
398
+ return;
322
399
  }
400
+ log.error('API middleware error:', err);
401
+ res.status(500).send(errorPage50x);
323
402
  }
324
- }
325
- return apiConfig;
403
+ };
326
404
  };