@constructive-io/graphql-server 3.1.1 → 4.0.1
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.
- package/errors/404-message.js +1 -1
- package/errors/api-errors.d.ts +200 -0
- package/errors/api-errors.js +276 -0
- package/esm/errors/404-message.js +1 -1
- package/esm/errors/api-errors.js +261 -0
- package/esm/index.js +2 -0
- package/esm/middleware/api.js +355 -277
- package/esm/middleware/auth.js +25 -7
- package/esm/middleware/error-handler.js +86 -0
- package/esm/middleware/favicon.js +12 -0
- package/esm/middleware/graphile.js +149 -64
- package/esm/options.js +232 -0
- package/esm/schema.js +24 -11
- package/esm/server.js +41 -5
- package/index.d.ts +1 -0
- package/index.js +2 -0
- package/middleware/api.d.ts +3 -15
- package/middleware/api.js +359 -283
- package/middleware/auth.js +25 -7
- package/middleware/error-handler.d.ts +4 -0
- package/middleware/error-handler.js +94 -0
- package/middleware/favicon.d.ts +2 -0
- package/middleware/favicon.js +16 -0
- package/middleware/graphile.d.ts +14 -0
- package/middleware/graphile.js +149 -64
- package/options.d.ts +131 -0
- package/options.js +244 -0
- package/package.json +23 -24
- package/schema.d.ts +2 -2
- package/schema.js +23 -10
- package/server.d.ts +24 -2
- package/server.js +39 -3
- package/codegen/orm/client.d.ts +0 -55
- package/codegen/orm/client.js +0 -75
- package/codegen/orm/index.d.ts +0 -36
- package/codegen/orm/index.js +0 -59
- package/codegen/orm/input-types.d.ts +0 -20140
- package/codegen/orm/input-types.js +0 -2
- package/codegen/orm/models/api.d.ts +0 -42
- package/codegen/orm/models/api.js +0 -76
- package/codegen/orm/models/domain.d.ts +0 -42
- package/codegen/orm/models/domain.js +0 -76
- package/codegen/orm/models/index.d.ts +0 -7
- package/codegen/orm/models/index.js +0 -12
- package/codegen/orm/mutation/index.d.ts +0 -7
- package/codegen/orm/mutation/index.js +0 -7
- package/codegen/orm/query/index.d.ts +0 -20
- package/codegen/orm/query/index.js +0 -24
- package/codegen/orm/query-builder.d.ts +0 -81
- package/codegen/orm/query-builder.js +0 -496
- package/codegen/orm/select-types.d.ts +0 -83
- package/codegen/orm/select-types.js +0 -7
- package/codegen/orm/types.d.ts +0 -6
- package/codegen/orm/types.js +0 -23
- package/esm/codegen/orm/client.js +0 -70
- package/esm/codegen/orm/index.js +0 -39
- package/esm/codegen/orm/input-types.js +0 -1
- package/esm/codegen/orm/models/api.js +0 -72
- package/esm/codegen/orm/models/domain.js +0 -72
- package/esm/codegen/orm/models/index.js +0 -7
- package/esm/codegen/orm/mutation/index.js +0 -4
- package/esm/codegen/orm/query/index.js +0 -21
- package/esm/codegen/orm/query-builder.js +0 -452
- package/esm/codegen/orm/select-types.js +0 -6
- package/esm/codegen/orm/types.js +0 -7
- package/esm/middleware/gql.js +0 -116
- package/esm/plugins/PublicKeySignature.js +0 -114
- package/esm/scripts/codegen-schema.js +0 -71
- package/esm/scripts/create-bucket.js +0 -40
- package/middleware/gql.d.ts +0 -164
- package/middleware/gql.js +0 -121
- package/plugins/PublicKeySignature.d.ts +0 -11
- package/plugins/PublicKeySignature.js +0 -121
- package/scripts/codegen-schema.d.ts +0 -1
- package/scripts/codegen-schema.js +0 -76
- package/scripts/create-bucket.d.ts +0 -1
- package/scripts/create-bucket.js +0 -42
package/esm/middleware/api.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.
|
|
28
|
-
.
|
|
29
|
-
|
|
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 = (
|
|
39
|
-
const
|
|
40
|
-
return
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
105
|
-
|
|
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: '
|
|
108
|
-
roleName: '
|
|
109
|
-
schema:
|
|
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
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
210
|
+
const resolveApiNameHeader = async (ctx) => {
|
|
211
|
+
const { opts, pool, headers } = ctx;
|
|
212
|
+
if (!headers.databaseId)
|
|
143
213
|
return null;
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
286
|
+
const pool = getPgPool(opts.pg);
|
|
186
287
|
const { domain, subdomains } = getUrlDomains(req);
|
|
187
288
|
const subdomain = getSubdomain(subdomains);
|
|
188
|
-
const
|
|
189
|
-
req.svc_key =
|
|
190
|
-
|
|
191
|
-
if (svcCache.has(
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
:
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
};
|