@platformatic/gateway 3.0.0-alpha.6

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 (49) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +13 -0
  3. package/config.d.ts +1000 -0
  4. package/eslint.config.js +12 -0
  5. package/index.d.ts +58 -0
  6. package/index.js +34 -0
  7. package/lib/application.js +186 -0
  8. package/lib/capability.js +89 -0
  9. package/lib/commands/index.js +15 -0
  10. package/lib/commands/openapi-fetch-schemas.js +48 -0
  11. package/lib/errors.js +18 -0
  12. package/lib/gateway-hook.js +66 -0
  13. package/lib/generator.js +127 -0
  14. package/lib/graphql-fetch.js +83 -0
  15. package/lib/graphql-generator.js +33 -0
  16. package/lib/graphql.js +29 -0
  17. package/lib/metrics.js +12 -0
  18. package/lib/not-host-constraints.js +31 -0
  19. package/lib/openapi-composer.js +101 -0
  20. package/lib/openapi-config-schema.js +89 -0
  21. package/lib/openapi-generator.js +215 -0
  22. package/lib/openapi-load-config.js +31 -0
  23. package/lib/openapi-modifier.js +131 -0
  24. package/lib/openapi-scalar.js +22 -0
  25. package/lib/proxy.js +266 -0
  26. package/lib/root.js +75 -0
  27. package/lib/schema.js +258 -0
  28. package/lib/upgrade.js +20 -0
  29. package/lib/utils.js +16 -0
  30. package/lib/versions/2.0.0.js +9 -0
  31. package/lib/versions/3.0.0.js +24 -0
  32. package/package.json +83 -0
  33. package/public/images/dark_mode.svg +3 -0
  34. package/public/images/ellipse.svg +21 -0
  35. package/public/images/external-link.svg +5 -0
  36. package/public/images/favicon.ico +0 -0
  37. package/public/images/graphiql.svg +10 -0
  38. package/public/images/graphql.svg +10 -0
  39. package/public/images/light_mode.svg +11 -0
  40. package/public/images/openapi.svg +13 -0
  41. package/public/images/platformatic-logo-dark.svg +30 -0
  42. package/public/images/platformatic-logo-light.svg +30 -0
  43. package/public/images/reverse-proxy.svg +8 -0
  44. package/public/images/triangle_dark.svg +3 -0
  45. package/public/images/triangle_light.svg +3 -0
  46. package/public/index.html +253 -0
  47. package/public/index.njk +101 -0
  48. package/public/main.css +244 -0
  49. package/schema.json +3779 -0
@@ -0,0 +1,131 @@
1
+ import traverse from 'json-schema-traverse'
2
+ import rfdc from 'rfdc'
3
+
4
+ const clone = rfdc()
5
+
6
+ const MODIFICATION_KEYWORDS = ['rename']
7
+
8
+ export const originPathSymbol = Symbol('originPath')
9
+
10
+ function findDataBySchemaPointer (schemaPointer, schema, data, parentData, callback) {
11
+ const schemaPointerParts = schemaPointer.split('/').slice(1)
12
+
13
+ for (const schemaPointerPart of schemaPointerParts) {
14
+ parentData = data
15
+ schema = schema[schemaPointerPart]
16
+
17
+ if (schemaPointerPart === 'properties') continue
18
+
19
+ if (schemaPointerPart === 'items') {
20
+ for (const item of data) {
21
+ const newSchemaPointer = '/' + schemaPointerParts.slice(1).join('/')
22
+ findDataBySchemaPointer(newSchemaPointer, schema, item, parentData, callback)
23
+ }
24
+ return
25
+ }
26
+
27
+ data = data[schemaPointerPart]
28
+ }
29
+
30
+ callback(data, parentData)
31
+ }
32
+
33
+ function getModificationRules (modificationSchema) {
34
+ const modificationRules = {}
35
+
36
+ function getModificationRules (schema, jsonPointer) {
37
+ const schemaKeys = Object.keys(schema)
38
+ const modificationKeys = schemaKeys.filter(key => MODIFICATION_KEYWORDS.includes(key))
39
+
40
+ if (modificationKeys.length === 0) return
41
+ modificationRules[jsonPointer] = schema
42
+ }
43
+
44
+ traverse(modificationSchema, { cb: getModificationRules })
45
+ return modificationRules
46
+ }
47
+
48
+ function modifySchema (originSchema, modificationRules) {
49
+ function modifyOriginSchema (schema, jsonPointer, rs, psp, pk, parentSchema, keyIndex) {
50
+ const modificationRule = modificationRules[jsonPointer]
51
+ if (!modificationRule) return
52
+
53
+ if (modificationRule.rename) {
54
+ parentSchema.properties[modificationRule.rename] = schema
55
+ delete parentSchema.properties[keyIndex]
56
+
57
+ if (parentSchema.required) {
58
+ const index = parentSchema.required.indexOf(keyIndex)
59
+ if (index !== -1) {
60
+ parentSchema.required[index] = modificationRule.rename
61
+ }
62
+ }
63
+ }
64
+ }
65
+ traverse(originSchema, { cb: modifyOriginSchema })
66
+ }
67
+
68
+ function modifyPayload (payload, originSchema, modificationRules) {
69
+ for (const schemaJsonPointer in modificationRules) {
70
+ const rule = modificationRules[schemaJsonPointer]
71
+
72
+ findDataBySchemaPointer(schemaJsonPointer, originSchema, payload, null, (data, parentData) => {
73
+ if (rule.rename) {
74
+ parentData[rule.rename] = data
75
+ delete parentData[schemaJsonPointer.split('/').pop()]
76
+ }
77
+ })
78
+ }
79
+ }
80
+
81
+ export function modifyOpenApiSchema (app, schema, config) {
82
+ const newSchemaPaths = {}
83
+ const { paths } = clone(schema)
84
+
85
+ for (let path in paths) {
86
+ const pathConfig = config?.paths?.[path]
87
+ const pathSchema = paths[path]
88
+
89
+ if (pathConfig?.ignore) continue
90
+
91
+ pathSchema[originPathSymbol] = path
92
+
93
+ if (pathConfig?.alias) {
94
+ path = pathConfig.alias
95
+ }
96
+
97
+ for (const method in pathSchema) {
98
+ const routeConfig = pathConfig?.[method.toLowerCase()]
99
+
100
+ if (routeConfig?.ignore) {
101
+ delete pathSchema[method]
102
+ continue
103
+ }
104
+
105
+ const modificationResponseSchema = routeConfig?.responses?.['200']
106
+ if (!modificationResponseSchema) continue
107
+
108
+ const modificationRules = getModificationRules(modificationResponseSchema)
109
+
110
+ function onRouteHook (routeOptions) {
111
+ const routeSchema = routeOptions.schema
112
+ const responseSchema = routeSchema.response?.['200']
113
+ modifySchema(responseSchema, modificationRules)
114
+
115
+ async function onGatewayResponse (request, reply, body) {
116
+ const payload = await body.json()
117
+ modifyPayload(payload, responseSchema, modificationRules)
118
+ reply.send(payload)
119
+ }
120
+ routeOptions.config.onGatewayResponse = onGatewayResponse
121
+ }
122
+
123
+ app.platformatic.addGatewayOnRouteHook(path, [method], onRouteHook)
124
+ app.platformatic.addComposerOnRouteHook(path, [method], onRouteHook)
125
+ }
126
+ if (Object.keys(pathSchema).length === 0) continue
127
+ newSchemaPaths[path] = pathSchema
128
+ }
129
+
130
+ return { ...schema, paths: newSchemaPaths }
131
+ }
@@ -0,0 +1,22 @@
1
+ import fp from 'fastify-plugin'
2
+
3
+ async function openApiScalarPlugin (app, opts) {
4
+ const { default: scalarTheme } = await import('@platformatic/scalar-theme')
5
+ const { default: scalarApiReference } = await import('@scalar/fastify-api-reference')
6
+
7
+ /** Serve spec file in yaml and json */
8
+ app.get('/documentation/json', { schema: { hide: true } }, async () => app.swagger())
9
+ app.get('/documentation/yaml', { schema: { hide: true } }, async () => app.swagger({ yaml: true }))
10
+
11
+ const routePrefix = opts.openapi?.swaggerPrefix || '/documentation'
12
+
13
+ await app.register(scalarApiReference, {
14
+ logLevel: 'warn',
15
+ routePrefix,
16
+ configuration: {
17
+ customCss: scalarTheme.theme
18
+ }
19
+ })
20
+ }
21
+
22
+ export const openApiScalar = fp(openApiScalarPlugin)
package/lib/proxy.js ADDED
@@ -0,0 +1,266 @@
1
+ import httpProxy from '@fastify/http-proxy'
2
+ import { ensureLoggableError, loadModule } from '@platformatic/foundation'
3
+ import fp from 'fastify-plugin'
4
+ import { createRequire } from 'node:module'
5
+ import { workerData } from 'node:worker_threads'
6
+ import { getGlobalDispatcher } from 'undici'
7
+ import { initMetrics } from './metrics.js'
8
+
9
+ const kITC = Symbol.for('plt.runtime.itc')
10
+ const kProxyRoute = Symbol('plt.gateway.proxy.route')
11
+
12
+ const urlPattern = /^https?:\/\//
13
+
14
+ async function resolveApplicationProxyParameters (application) {
15
+ // Get meta information from the application, if any, to eventually hook up to a TCP port
16
+ const allMeta = (await globalThis[kITC]?.send('getApplicationMeta', application.id)) ?? {}
17
+ const meta = allMeta.gateway ?? allMeta.composer ?? { prefix: application.id }
18
+
19
+ // If no prefix could be found, assume the application id
20
+ let prefix = (application.proxy?.prefix ?? meta.prefix ?? application.id).replace(/(\/$)/g, '')
21
+ let rewritePrefix = ''
22
+ let internalRewriteLocationHeader = true
23
+
24
+ if (meta.wantsAbsoluteUrls) {
25
+ const basePath = workerData.config.basePath
26
+
27
+ // Strip the runtime basepath from the prefix when it comes from the application meta
28
+ if (basePath && !application.proxy?.prefix && prefix.startsWith(basePath)) {
29
+ prefix = prefix.substring(basePath.length)
30
+ }
31
+
32
+ // The rewritePrefix purposely ignores application.proxy?.prefix to let
33
+ // the application always being able to configure their value
34
+ rewritePrefix = meta.prefix ?? application.id
35
+ internalRewriteLocationHeader = false
36
+ }
37
+
38
+ if (application.proxy?.ws?.hooks) {
39
+ const hooks = await loadModule(createRequire(import.meta.filename), application.proxy.ws.hooks.path)
40
+ application.proxy.ws.hooks = hooks
41
+ }
42
+
43
+ return {
44
+ origin: application.origin,
45
+ url: meta.url,
46
+ prefix,
47
+ rewritePrefix,
48
+ internalRewriteLocationHeader,
49
+ needsRootTrailingSlash: meta.needsRootTrailingSlash,
50
+ needsRefererBasedRedirect: meta.needsRefererBasedRedirect,
51
+ upstream: application.proxy?.upstream,
52
+ ws: application.proxy?.ws
53
+ }
54
+ }
55
+
56
+ let metrics
57
+
58
+ async function proxyPlugin (app, opts) {
59
+ const meta = { proxies: {} }
60
+ const hostnameLessProxies = []
61
+
62
+ for (const application of opts.applications) {
63
+ if (!application.proxy) {
64
+ // When a application defines no expose config at all
65
+ // we assume a proxy exposed with a prefix equals to its id or meta.prefix
66
+ if (application.proxy === false || application.openapi || application.graphql) {
67
+ continue
68
+ }
69
+ }
70
+
71
+ const parameters = await resolveApplicationProxyParameters(application)
72
+ const {
73
+ prefix,
74
+ origin,
75
+ url,
76
+ rewritePrefix,
77
+ internalRewriteLocationHeader,
78
+ needsRootTrailingSlash,
79
+ needsRefererBasedRedirect,
80
+ ws
81
+ } = parameters
82
+ meta.proxies[application.id] = parameters
83
+
84
+ const basePath = `/${prefix ?? ''}`.replaceAll(/\/+/g, '/').replace(/\/$/, '')
85
+ const dispatcher = getGlobalDispatcher()
86
+
87
+ let preRewrite = null
88
+
89
+ if (needsRootTrailingSlash) {
90
+ preRewrite = function preRewrite (url) {
91
+ if (url === basePath) {
92
+ url += '/'
93
+ }
94
+
95
+ return url
96
+ }
97
+ }
98
+
99
+ /*
100
+ Some frontends, like Astro (https://github.com/withastro/astro/issues/11445)
101
+ generate invalid paths in development mode which ignore the basePath.
102
+ In that case we try to properly redirect the browser by trying to understand the prefix
103
+ from the Referer header.
104
+ */
105
+ if (needsRefererBasedRedirect) {
106
+ app.addHook('preHandler', function refererBasedRedirectPreHandler (req, reply, done) {
107
+ // If the URL is already targeted to the application, do nothing
108
+ if (req.url.startsWith(basePath)) {
109
+ done()
110
+ return
111
+ }
112
+
113
+ // Use the referer to understand the desired intent
114
+ const referer = req.headers.referer
115
+
116
+ if (!referer) {
117
+ done()
118
+ return
119
+ }
120
+
121
+ const path = new URL(referer).pathname
122
+
123
+ // If we have a match redirect
124
+ if (path.startsWith(basePath)) {
125
+ reply.redirect(`${basePath}${req.url}`, 308)
126
+ }
127
+
128
+ done()
129
+ })
130
+ }
131
+
132
+ // Do not show proxied applications in Swagger
133
+ if (!application.openapi) {
134
+ app.addHook('onRoute', routeOptions => {
135
+ if (routeOptions.config?.[kProxyRoute] && routeOptions.url.startsWith(basePath)) {
136
+ routeOptions.schema ??= {}
137
+ routeOptions.schema.hide = true
138
+ }
139
+ })
140
+ }
141
+
142
+ const toReplace = url
143
+ ? new RegExp(
144
+ url
145
+ .replace(/127\.0\.0\.1/, 'localhost')
146
+ .replace(/\[::\]/, 'localhost')
147
+ .replace('http://', 'https?://')
148
+ )
149
+ : null
150
+
151
+ if (!metrics) {
152
+ metrics = initMetrics(globalThis.platformatic?.prometheus)
153
+ }
154
+
155
+ const proxyOptions = {
156
+ prefix,
157
+ rewritePrefix,
158
+ upstream: application.proxy?.upstream ?? origin,
159
+ preRewrite,
160
+
161
+ websocket: true,
162
+ wsUpstream: ws?.upstream ?? url ?? origin,
163
+ wsReconnect: ws?.reconnect,
164
+ wsHooks: {
165
+ onConnect: (...args) => {
166
+ metrics?.activeWsConnections?.inc()
167
+ ws?.hooks?.onConnect(...args)
168
+ },
169
+ onDisconnect: (...args) => {
170
+ metrics?.activeWsConnections?.dec()
171
+ ws?.hooks?.onDisconnect(...args)
172
+ },
173
+ onReconnect: ws?.hooks?.onReconnect,
174
+ onPong: ws?.hooks?.onPong,
175
+ onIncomingMessage: ws?.hooks?.onIncomingMessage,
176
+ onOutgoingMessage: ws?.hooks?.onOutgoingMessage
177
+ },
178
+
179
+ undici: dispatcher,
180
+ destroyAgent: false,
181
+ config: {
182
+ [kProxyRoute]: true
183
+ },
184
+
185
+ internalRewriteLocationHeader: false,
186
+ replyOptions: {
187
+ rewriteHeaders: headers => {
188
+ let location = headers.location
189
+ if (location) {
190
+ if (toReplace) {
191
+ location = location.replace(toReplace, '')
192
+ }
193
+ if (!urlPattern.test(location) && internalRewriteLocationHeader) {
194
+ location = location.replace(rewritePrefix, prefix)
195
+ }
196
+ headers.location = location
197
+ }
198
+ return headers
199
+ },
200
+ rewriteRequestHeaders: (request, headers) => {
201
+ const targetUrl = `${origin}${request.url}`
202
+ const context = request.span?.context
203
+ const { span, telemetryHeaders } = app.openTelemetry?.startHTTPSpanClient(
204
+ targetUrl,
205
+ request.method,
206
+ context
207
+ ) || { span: null, telemetryHeaders: {} }
208
+ // We need to store the span in a different object
209
+ // to correctly close it in the onResponse hook
210
+ // Note that we have 2 spans:
211
+ // - request.span: the span of the request to the proxy
212
+ // - request.proxedCallSpan: the span of the request to the proxied application
213
+ request.proxedCallSpan = span
214
+
215
+ headers = {
216
+ ...headers,
217
+ ...telemetryHeaders,
218
+ 'x-forwarded-for': request.ip,
219
+ 'x-forwarded-host': request.host,
220
+ 'x-forwarded-proto': request.protocol
221
+ }
222
+
223
+ request.log.trace({ headers }, 'rewritten headers before proxying')
224
+
225
+ return headers
226
+ },
227
+ onResponse: (_, reply, res) => {
228
+ app.openTelemetry?.endHTTPSpanClient(reply.request.proxedCallSpan, {
229
+ statusCode: reply.statusCode,
230
+ headers: res.headers
231
+ })
232
+ reply.send(res.stream)
233
+ },
234
+ onError: (reply, { error }) => {
235
+ app.log.error({ error: ensureLoggableError(error) }, 'Error while proxying request to another application')
236
+ return reply.send(error)
237
+ }
238
+ }
239
+ }
240
+
241
+ hostnameLessProxies.push(proxyOptions)
242
+
243
+ const host = application.proxy?.hostname
244
+
245
+ if (host) {
246
+ await app.register(httpProxy, {
247
+ ...proxyOptions,
248
+ prefix: '/',
249
+ constraints: { host }
250
+ })
251
+ }
252
+ }
253
+
254
+ const hostnames = opts.applications.map(s => s.proxy?.hostname).filter(Boolean)
255
+ for (const options of hostnameLessProxies) {
256
+ if (hostnames.length > 0) {
257
+ options.constraints = { notHost: hostnames }
258
+ }
259
+
260
+ await app.register(httpProxy, options)
261
+ }
262
+
263
+ opts.capability?.registerMeta(meta)
264
+ }
265
+
266
+ export const proxy = fp(proxyPlugin)
package/lib/root.js ADDED
@@ -0,0 +1,75 @@
1
+ import fastifyStatic from '@fastify/static'
2
+ import fastifyView from '@fastify/view'
3
+ import userAgentParser from 'my-ua-parser'
4
+ import { join } from 'node:path'
5
+ import nunjucks from 'nunjucks'
6
+
7
+ export default function root (app) {
8
+ app.register(fastifyStatic, {
9
+ root: join(import.meta.dirname, '../public')
10
+ })
11
+ app.register(fastifyView, {
12
+ engine: {
13
+ nunjucks
14
+ },
15
+ root: join(import.meta.dirname, '../public')
16
+ })
17
+ // root endpoint
18
+ app.route({
19
+ method: 'GET',
20
+ path: '/',
21
+ schema: { hide: true },
22
+ handler: async (req, reply) => {
23
+ const uaString = req.headers['user-agent']
24
+ let hasOpenAPIServices = false
25
+ let hasGraphQLServices = false
26
+ if (uaString) {
27
+ const parsed = userAgentParser(uaString)
28
+ if (parsed.browser.name !== undefined) {
29
+ const serviceTypes = {
30
+ proxy: {
31
+ title: 'Reverse Proxy',
32
+ icon: './images/reverse-proxy.svg',
33
+ services: []
34
+ },
35
+ openapi: {
36
+ title: 'OpenAPI',
37
+ icon: './images/openapi.svg',
38
+ services: []
39
+ },
40
+ graphql: {
41
+ title: 'GraphQL',
42
+ icon: './images/graphql.svg',
43
+ services: []
44
+ }
45
+ }
46
+
47
+ app.platformatic.config.gateway.applications.forEach(s => {
48
+ if (s.openapi) {
49
+ hasOpenAPIServices = true
50
+ serviceTypes.openapi.services.push(s)
51
+ }
52
+ if (s.graphql) {
53
+ hasGraphQLServices = true
54
+ serviceTypes.graphql.services.push(s)
55
+ }
56
+ if (s.proxy) {
57
+ serviceTypes.proxy.services.push({
58
+ ...s,
59
+ externalLink: `${s.proxy.prefix}/`
60
+ })
61
+ }
62
+ })
63
+
64
+ return reply.view('index.njk', {
65
+ hasGraphQLServices,
66
+ hasOpenAPIServices,
67
+ services: serviceTypes
68
+ })
69
+ }
70
+ }
71
+ // Load services
72
+ return { message: 'Welcome to Platformatic! Please visit https://docs.platformatic.dev' }
73
+ }
74
+ })
75
+ }