@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.
- package/LICENSE +201 -0
- package/NOTICE +13 -0
- package/config.d.ts +1000 -0
- package/eslint.config.js +12 -0
- package/index.d.ts +58 -0
- package/index.js +34 -0
- package/lib/application.js +186 -0
- package/lib/capability.js +89 -0
- package/lib/commands/index.js +15 -0
- package/lib/commands/openapi-fetch-schemas.js +48 -0
- package/lib/errors.js +18 -0
- package/lib/gateway-hook.js +66 -0
- package/lib/generator.js +127 -0
- package/lib/graphql-fetch.js +83 -0
- package/lib/graphql-generator.js +33 -0
- package/lib/graphql.js +29 -0
- package/lib/metrics.js +12 -0
- package/lib/not-host-constraints.js +31 -0
- package/lib/openapi-composer.js +101 -0
- package/lib/openapi-config-schema.js +89 -0
- package/lib/openapi-generator.js +215 -0
- package/lib/openapi-load-config.js +31 -0
- package/lib/openapi-modifier.js +131 -0
- package/lib/openapi-scalar.js +22 -0
- package/lib/proxy.js +266 -0
- package/lib/root.js +75 -0
- package/lib/schema.js +258 -0
- package/lib/upgrade.js +20 -0
- package/lib/utils.js +16 -0
- package/lib/versions/2.0.0.js +9 -0
- package/lib/versions/3.0.0.js +24 -0
- package/package.json +83 -0
- package/public/images/dark_mode.svg +3 -0
- package/public/images/ellipse.svg +21 -0
- package/public/images/external-link.svg +5 -0
- package/public/images/favicon.ico +0 -0
- package/public/images/graphiql.svg +10 -0
- package/public/images/graphql.svg +10 -0
- package/public/images/light_mode.svg +11 -0
- package/public/images/openapi.svg +13 -0
- package/public/images/platformatic-logo-dark.svg +30 -0
- package/public/images/platformatic-logo-light.svg +30 -0
- package/public/images/reverse-proxy.svg +8 -0
- package/public/images/triangle_dark.svg +3 -0
- package/public/images/triangle_light.svg +3 -0
- package/public/index.html +253 -0
- package/public/index.njk +101 -0
- package/public/main.css +244 -0
- package/schema.json +3779 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { compose } from '@platformatic/graphql-composer'
|
|
2
|
+
|
|
3
|
+
const placeholderSdl = 'Query { _info: String }'
|
|
4
|
+
const placeholderResolvers = { Query: { _info: '@platformatic/gateway' } }
|
|
5
|
+
|
|
6
|
+
// TODO support subscriptions
|
|
7
|
+
// const defaultSubscriptionsOptions = {
|
|
8
|
+
// onError: function onGatewaySubscriptionsError (ctx, topic, err) {
|
|
9
|
+
// // TODO log.error({err})
|
|
10
|
+
// throw err
|
|
11
|
+
// },
|
|
12
|
+
// publish (ctx, topic, payload) {
|
|
13
|
+
// ctx.pubsub.publish({ topic, payload })
|
|
14
|
+
// },
|
|
15
|
+
// subscribe (ctx, topic) {
|
|
16
|
+
// return ctx.pubsub.subscribe(topic)
|
|
17
|
+
// },
|
|
18
|
+
// unsubscribe (ctx, topic) {
|
|
19
|
+
// ctx.pubsub.close()
|
|
20
|
+
// }
|
|
21
|
+
// }
|
|
22
|
+
|
|
23
|
+
function toGatewayOptions (options, app) {
|
|
24
|
+
return {
|
|
25
|
+
logger: app.log,
|
|
26
|
+
defaultArgsAdapter: options?.defaultArgsAdapter,
|
|
27
|
+
addEntitiesResolvers: options?.addEntitiesResolvers,
|
|
28
|
+
entities: options?.entities,
|
|
29
|
+
onSubgraphError: (err, subgraphName) => {
|
|
30
|
+
app.log.error({ err }, 'graphql composer error on subgraph ' + subgraphName)
|
|
31
|
+
|
|
32
|
+
if (options?.onSubgraphError) {
|
|
33
|
+
try {
|
|
34
|
+
options.onSubgraphError(err, subgraphName)
|
|
35
|
+
} catch (err) {
|
|
36
|
+
app.log.error({ err }, 'running onSubgraphError')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createSupergraph ({ sdl = null, resolvers = {} } = {}) {
|
|
44
|
+
// in case of temporary failures of subgraphs on watching, the application can restart if no subgraphs are (tempoary) available
|
|
45
|
+
if (!sdl) {
|
|
46
|
+
return {
|
|
47
|
+
sdl: placeholderSdl,
|
|
48
|
+
resolvers: placeholderResolvers
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { sdl, resolvers }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isSameGraphqlSchema (a, b) {
|
|
55
|
+
// TODO review
|
|
56
|
+
return a?.sdl === b?.sdl
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function applicationToSubgraphConfig (application) {
|
|
60
|
+
if (!application.graphql) {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
name: application.graphql.name || application.id || application.origin,
|
|
65
|
+
entities: application.graphql.entities,
|
|
66
|
+
server: {
|
|
67
|
+
host: application.graphql.host || application.origin,
|
|
68
|
+
composeEndpoint: application.graphql.composeEndpoint,
|
|
69
|
+
graphqlEndpoint: application.graphql.graphqlEndpoint
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function fetchGraphqlSubgraphs (applications, options, app) {
|
|
75
|
+
const subgraphs = applications.map(applicationToSubgraphConfig).filter(s => !!s)
|
|
76
|
+
const gateway = await compose({ ...toGatewayOptions(options, app), subgraphs })
|
|
77
|
+
|
|
78
|
+
return createSupergraph({
|
|
79
|
+
logger: app.log,
|
|
80
|
+
sdl: gateway.toSdl(),
|
|
81
|
+
resolvers: gateway.resolvers
|
|
82
|
+
})
|
|
83
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fp from 'fastify-plugin'
|
|
2
|
+
import mercurius from 'mercurius'
|
|
3
|
+
import { fetchGraphqlSubgraphs } from './graphql-fetch.js'
|
|
4
|
+
|
|
5
|
+
async function graphqlGeneratorPlugin (app, opts) {
|
|
6
|
+
if (!opts.applications.some(s => s.graphql)) {
|
|
7
|
+
return
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const applications = []
|
|
11
|
+
|
|
12
|
+
for (const application of opts.applications) {
|
|
13
|
+
if (!application.graphql) {
|
|
14
|
+
continue
|
|
15
|
+
}
|
|
16
|
+
applications.push(application)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const graphqlConfig = {
|
|
20
|
+
graphiql: opts.graphql?.graphiql
|
|
21
|
+
}
|
|
22
|
+
if (applications.length > 0) {
|
|
23
|
+
const graphqlSupergraph = await fetchGraphqlSubgraphs(applications, opts.graphql, app)
|
|
24
|
+
graphqlConfig.schema = graphqlSupergraph.sdl
|
|
25
|
+
graphqlConfig.resolvers = graphqlSupergraph.resolvers
|
|
26
|
+
graphqlConfig.subscription = false // TODO support subscriptions, will be !!opts.graphql.subscriptions
|
|
27
|
+
app.graphqlSupergraph = graphqlSupergraph
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await app.register(mercurius, graphqlConfig)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const graphqlGenerator = fp(graphqlGeneratorPlugin)
|
package/lib/graphql.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fp from 'fastify-plugin'
|
|
2
|
+
import { createSupergraph } from './graphql-fetch.js'
|
|
3
|
+
|
|
4
|
+
const graphqlSupergraphSymbol = Symbol('graphqlSupergraph')
|
|
5
|
+
|
|
6
|
+
export async function graphqlPlugin (app, opts) {
|
|
7
|
+
app.decorate('graphqlSupergraph', {
|
|
8
|
+
getter () {
|
|
9
|
+
return this[graphqlSupergraphSymbol]
|
|
10
|
+
},
|
|
11
|
+
setter (v) {
|
|
12
|
+
this[graphqlSupergraphSymbol] = v
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
app.decorate('graphqlGatewayOptions', {
|
|
16
|
+
getter () {
|
|
17
|
+
return opts
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
app.decorate('graphqlComposerOptions', {
|
|
21
|
+
getter () {
|
|
22
|
+
return opts
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
app.graphqlSupergraph = createSupergraph()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const graphql = fp(graphqlPlugin)
|
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function initMetrics (prometheus) {
|
|
2
|
+
if (!prometheus?.registry || !prometheus?.client) return null
|
|
3
|
+
const { client, registry } = prometheus
|
|
4
|
+
|
|
5
|
+
return {
|
|
6
|
+
activeWsConnections: new client.Gauge({
|
|
7
|
+
name: 'active_ws_gateway_connections',
|
|
8
|
+
help: 'Active Websocket gateway connections in "@platformatic/gateway"',
|
|
9
|
+
registers: [registry]
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const notHostConstraints = {
|
|
2
|
+
name: 'notHost',
|
|
3
|
+
storage () {
|
|
4
|
+
const store = []
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
get (host) {
|
|
8
|
+
if (typeof host === 'string') {
|
|
9
|
+
for (const [hosts, value] of store) {
|
|
10
|
+
if (!hosts.includes(host)) {
|
|
11
|
+
return value
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return null
|
|
17
|
+
},
|
|
18
|
+
set: (hosts, value) => {
|
|
19
|
+
store.push([hosts, value])
|
|
20
|
+
},
|
|
21
|
+
store
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
deriveConstraint (req) {
|
|
25
|
+
return req.headers.host || req.headers[':authority']
|
|
26
|
+
},
|
|
27
|
+
mustMatchWhenDerived: false,
|
|
28
|
+
validate () {
|
|
29
|
+
return true
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import rfdc from 'rfdc'
|
|
2
|
+
import { PathAlreadyExistsError } from './errors.js'
|
|
3
|
+
|
|
4
|
+
const clone = rfdc()
|
|
5
|
+
|
|
6
|
+
function generateOperationIdApiPrefix (operationId) {
|
|
7
|
+
return (
|
|
8
|
+
operationId
|
|
9
|
+
.trim()
|
|
10
|
+
.replace(/[^A-Z0-9]+/gi, '_')
|
|
11
|
+
.replace(/^_+|_+$/g, '') + '_'
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function namespaceSchemaRefs (apiPrefix, schema) {
|
|
16
|
+
if (schema.$ref && schema.$ref.startsWith('#/components/schemas')) {
|
|
17
|
+
schema.$ref = schema.$ref.replace('#/components/schemas/', '#/components/schemas/' + apiPrefix)
|
|
18
|
+
}
|
|
19
|
+
for (const childSchema of Object.values(schema)) {
|
|
20
|
+
if (typeof childSchema === 'object') {
|
|
21
|
+
namespaceSchemaRefs(apiPrefix, childSchema)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function namespaceSchemaOperationIds (apiPrefix, schema) {
|
|
27
|
+
if (schema.operationId) {
|
|
28
|
+
schema.operationId = apiPrefix + schema.operationId
|
|
29
|
+
}
|
|
30
|
+
for (const childSchema of Object.values(schema)) {
|
|
31
|
+
if (typeof childSchema === 'object') {
|
|
32
|
+
namespaceSchemaOperationIds(apiPrefix, childSchema)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function composeOpenApi (apis, options = {}) {
|
|
38
|
+
const mergedPaths = {}
|
|
39
|
+
const mergedSchemas = {}
|
|
40
|
+
const mergedSecuritySchemes = {}
|
|
41
|
+
|
|
42
|
+
for (const { id, prefix, schema } of apis) {
|
|
43
|
+
const { paths, components } = clone(schema)
|
|
44
|
+
|
|
45
|
+
const apiPrefix = generateOperationIdApiPrefix(id)
|
|
46
|
+
for (const [path, pathSchema] of Object.entries(paths)) {
|
|
47
|
+
namespaceSchemaRefs(apiPrefix, pathSchema)
|
|
48
|
+
namespaceSchemaOperationIds(apiPrefix, pathSchema)
|
|
49
|
+
|
|
50
|
+
for (const methodSchema of Object.values(pathSchema)) {
|
|
51
|
+
if (methodSchema.security) {
|
|
52
|
+
methodSchema.security = methodSchema.security.map(security => {
|
|
53
|
+
const newSecurity = {}
|
|
54
|
+
for (const [securityKey, securityValue] of Object.entries(security)) {
|
|
55
|
+
newSecurity[apiPrefix + securityKey] = securityValue
|
|
56
|
+
}
|
|
57
|
+
return newSecurity
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const mergedPath = prefix ? prefix + path : path
|
|
63
|
+
|
|
64
|
+
if (mergedPaths[mergedPath]) {
|
|
65
|
+
throw new PathAlreadyExistsError(mergedPath)
|
|
66
|
+
}
|
|
67
|
+
mergedPaths[mergedPath] = pathSchema
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (components) {
|
|
71
|
+
if (components.schemas) {
|
|
72
|
+
for (const [schemaKey, schema] of Object.entries(components.schemas)) {
|
|
73
|
+
if (schema.title == null) {
|
|
74
|
+
schema.title = schemaKey
|
|
75
|
+
}
|
|
76
|
+
namespaceSchemaRefs(apiPrefix, schema)
|
|
77
|
+
mergedSchemas[apiPrefix + schemaKey] = schema
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (components.securitySchemes) {
|
|
82
|
+
for (const [securitySchemeKey, securityScheme] of Object.entries(components.securitySchemes)) {
|
|
83
|
+
mergedSecuritySchemes[apiPrefix + securitySchemeKey] = securityScheme
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
openapi: '3.0.0',
|
|
91
|
+
info: {
|
|
92
|
+
title: options.title || 'Platformatic Gateway',
|
|
93
|
+
version: options.version || '1.0.0'
|
|
94
|
+
},
|
|
95
|
+
components: {
|
|
96
|
+
securitySchemes: mergedSecuritySchemes,
|
|
97
|
+
schemas: mergedSchemas
|
|
98
|
+
},
|
|
99
|
+
paths: mergedPaths
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const ignoreSchema = {
|
|
2
|
+
type: 'object',
|
|
3
|
+
properties: {
|
|
4
|
+
ignore: { type: 'boolean' }
|
|
5
|
+
},
|
|
6
|
+
additionalProperties: false
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const aliasSchema = {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
alias: { type: 'string' }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const jsonSchemaSchema = {
|
|
17
|
+
$id: 'json-schema',
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
type: { type: 'string' },
|
|
21
|
+
properties: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
additionalProperties: {
|
|
24
|
+
oneOf: [
|
|
25
|
+
{ $ref: 'json-schema' },
|
|
26
|
+
{
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
rename: { type: 'string' }
|
|
30
|
+
},
|
|
31
|
+
additionalProperties: false
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
items: { $ref: 'json-schema' }
|
|
37
|
+
},
|
|
38
|
+
additionalProperties: false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const routeSchema = {
|
|
42
|
+
anyOf: [
|
|
43
|
+
ignoreSchema,
|
|
44
|
+
{
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
responses: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
200: { $ref: 'json-schema' }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const openApiConfigSchema = {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
paths: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
additionalProperties: {
|
|
64
|
+
anyOf: [
|
|
65
|
+
ignoreSchema,
|
|
66
|
+
aliasSchema,
|
|
67
|
+
{
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
get: routeSchema,
|
|
71
|
+
post: routeSchema,
|
|
72
|
+
put: routeSchema,
|
|
73
|
+
patch: routeSchema,
|
|
74
|
+
delete: routeSchema,
|
|
75
|
+
options: routeSchema,
|
|
76
|
+
head: routeSchema,
|
|
77
|
+
trace: routeSchema
|
|
78
|
+
},
|
|
79
|
+
additionalProperties: false
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
definitions: {
|
|
87
|
+
'json-schema': jsonSchemaSchema
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import fastifyReplyFrom from '@fastify/reply-from'
|
|
2
|
+
import fastifySwagger from '@fastify/swagger'
|
|
3
|
+
import fp from 'fastify-plugin'
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
5
|
+
import { getGlobalDispatcher, request } from 'undici'
|
|
6
|
+
import { CouldNotReadOpenAPIConfigError } from './errors.js'
|
|
7
|
+
import { composeOpenApi } from './openapi-composer.js'
|
|
8
|
+
import { loadOpenApiConfig } from './openapi-load-config.js'
|
|
9
|
+
import { modifyOpenApiSchema, originPathSymbol } from './openapi-modifier.js'
|
|
10
|
+
import { openApiScalar } from './openapi-scalar.js'
|
|
11
|
+
import { prefixWithSlash } from './utils.js'
|
|
12
|
+
|
|
13
|
+
async function fetchOpenApiSchema (openApiUrl) {
|
|
14
|
+
const { body } = await request(openApiUrl)
|
|
15
|
+
return body.json()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readOpenApiSchema (pathToSchema) {
|
|
19
|
+
const schemaFile = await readFile(pathToSchema, 'utf-8')
|
|
20
|
+
return JSON.parse(schemaFile)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function getOpenApiSchema (origin, openapi) {
|
|
24
|
+
if (openapi.url) {
|
|
25
|
+
const openApiUrl = origin + prefixWithSlash(openapi.url)
|
|
26
|
+
return fetchOpenApiSchema(openApiUrl)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return readOpenApiSchema(openapi.file)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createPathMapper (originOpenApiPath, renamedOpenApiPath, prefix) {
|
|
33
|
+
if (prefix + originOpenApiPath === renamedOpenApiPath) {
|
|
34
|
+
return path => path.slice(prefix.length)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const extractParamsRegexp = generateRouteRegex(renamedOpenApiPath)
|
|
38
|
+
return path => {
|
|
39
|
+
const routeParams = path.match(extractParamsRegexp).slice(1)
|
|
40
|
+
return generateRenamedPath(originOpenApiPath, routeParams)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function generateRouteRegex (route) {
|
|
45
|
+
const regex = route.replace(/{(.*?)}/g, '(.*)')
|
|
46
|
+
return new RegExp(regex)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function generateRenamedPath (renamedOpenApiPath, routeParams) {
|
|
50
|
+
return renamedOpenApiPath.replace(/{(.*?)}/g, () => routeParams.shift())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function openApiGatewayPlugin (app, { opts, generated }) {
|
|
54
|
+
const { apiByApiRoutes } = generated
|
|
55
|
+
|
|
56
|
+
const dispatcher = getGlobalDispatcher()
|
|
57
|
+
|
|
58
|
+
await app.register(fastifyReplyFrom, {
|
|
59
|
+
undici: dispatcher,
|
|
60
|
+
destroyAgent: false
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
await app.register(await import('@platformatic/fastify-openapi-glue'), {
|
|
64
|
+
specification: app.composedOpenApiSchema,
|
|
65
|
+
addEmptySchema: opts.addEmptySchema,
|
|
66
|
+
operationResolver: (operationId, method, openApiPath) => {
|
|
67
|
+
const { origin, prefix, schema } = apiByApiRoutes[openApiPath]
|
|
68
|
+
const originPath = schema[originPathSymbol]
|
|
69
|
+
|
|
70
|
+
const mapRoutePath = createPathMapper(originPath, openApiPath, prefix)
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
config: { openApiPath },
|
|
74
|
+
handler: (req, reply) => {
|
|
75
|
+
const routePath = req.raw.url.split('?')[0]
|
|
76
|
+
const newRoutePath = mapRoutePath(routePath)
|
|
77
|
+
|
|
78
|
+
const replyOptions = {}
|
|
79
|
+
const onResponse = (request, reply, res) => {
|
|
80
|
+
app.openTelemetry?.endHTTPSpanClient(reply.request.proxedCallSpan, {
|
|
81
|
+
statusCode: reply.statusCode,
|
|
82
|
+
headers: res.headers
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const onResponse = req.routeOptions.config?.onGatewayResponse ?? req.routeOptions.config?.onComposerResponse
|
|
86
|
+
if (onResponse) {
|
|
87
|
+
onResponse(request, reply, res.stream)
|
|
88
|
+
} else {
|
|
89
|
+
reply.send(res.stream)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const rewriteRequestHeaders = (request, headers) => {
|
|
93
|
+
const targetUrl = `${origin}${request.url}`
|
|
94
|
+
const context = request.span?.context
|
|
95
|
+
const { span, telemetryHeaders } = app.openTelemetry?.startHTTPSpanClient(
|
|
96
|
+
targetUrl,
|
|
97
|
+
request.method,
|
|
98
|
+
context
|
|
99
|
+
) || { span: null, telemetryHeaders: {} }
|
|
100
|
+
// We need to store the span in a different object
|
|
101
|
+
// to correctly close it in the onResponse hook
|
|
102
|
+
// Note that we have 2 spans:
|
|
103
|
+
// - request.span: the span of the request to the proxy
|
|
104
|
+
// - request.proxedCallSpan: the span of the request to the proxied application
|
|
105
|
+
request.proxedCallSpan = span
|
|
106
|
+
|
|
107
|
+
headers = {
|
|
108
|
+
...headers,
|
|
109
|
+
...telemetryHeaders,
|
|
110
|
+
'x-forwarded-for': request.ip,
|
|
111
|
+
'x-forwarded-host': request.host
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return headers
|
|
115
|
+
}
|
|
116
|
+
replyOptions.onResponse = onResponse
|
|
117
|
+
replyOptions.rewriteRequestHeaders = rewriteRequestHeaders
|
|
118
|
+
|
|
119
|
+
reply.from(origin + newRoutePath, replyOptions)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
app.addHook('preValidation', async req => {
|
|
126
|
+
if (typeof req.query.fields === 'string') {
|
|
127
|
+
req.query.fields = req.query.fields.split(',')
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function openApiGenerator (app, opts) {
|
|
133
|
+
if (!opts.applications.some(s => s.openapi)) {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { applications } = opts
|
|
138
|
+
|
|
139
|
+
const openApiSchemas = []
|
|
140
|
+
const apiByApiRoutes = {}
|
|
141
|
+
|
|
142
|
+
for (const { id, origin, openapi } of applications) {
|
|
143
|
+
if (!openapi) continue
|
|
144
|
+
|
|
145
|
+
let openapiConfig = null
|
|
146
|
+
if (openapi.config) {
|
|
147
|
+
try {
|
|
148
|
+
openapiConfig = await loadOpenApiConfig(openapi.config)
|
|
149
|
+
} catch (error) {
|
|
150
|
+
app.log.error(error)
|
|
151
|
+
throw new CouldNotReadOpenAPIConfigError(id)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let originSchema = null
|
|
156
|
+
try {
|
|
157
|
+
originSchema = await getOpenApiSchema(origin, openapi)
|
|
158
|
+
} catch (error) {
|
|
159
|
+
app.log.error(error, `failed to fetch schema for "${id} application"`)
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const schema = modifyOpenApiSchema(app, originSchema, openapiConfig)
|
|
164
|
+
|
|
165
|
+
const prefix = openapi.prefix ? prefixWithSlash(openapi.prefix) : ''
|
|
166
|
+
for (const path in schema.paths) {
|
|
167
|
+
apiByApiRoutes[prefix + path] = {
|
|
168
|
+
origin,
|
|
169
|
+
prefix,
|
|
170
|
+
schema: schema.paths[path]
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
openApiSchemas.push({ id, prefix, schema, originSchema, config: openapiConfig })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const composedOpenApiSchema = composeOpenApi(openApiSchemas, opts.openapi)
|
|
178
|
+
|
|
179
|
+
app.decorate('openApiSchemas', openApiSchemas)
|
|
180
|
+
app.decorate('composedOpenApiSchema', composedOpenApiSchema)
|
|
181
|
+
|
|
182
|
+
await app.register(fastifySwagger, {
|
|
183
|
+
exposeRoute: true,
|
|
184
|
+
openapi: {
|
|
185
|
+
info: {
|
|
186
|
+
title: opts.openapi?.title || 'Platformatic Gateway',
|
|
187
|
+
version: opts.openapi?.version || '1.0.0'
|
|
188
|
+
},
|
|
189
|
+
servers: [{ url: globalThis.platformatic?.runtimeBasePath ?? '/' }],
|
|
190
|
+
components: app.composedOpenApiSchema.components
|
|
191
|
+
},
|
|
192
|
+
transform ({ schema, url }) {
|
|
193
|
+
for (const application of opts.applications) {
|
|
194
|
+
if (!application.proxy) continue
|
|
195
|
+
|
|
196
|
+
const prefix = application.proxy.prefix ?? ''
|
|
197
|
+
const proxyPrefix = prefix.at(-1) === '/' ? prefix.slice(0, -1) : prefix
|
|
198
|
+
|
|
199
|
+
const proxyUrls = [proxyPrefix + '/', proxyPrefix + '/*']
|
|
200
|
+
if (proxyUrls.includes(url)) {
|
|
201
|
+
schema = schema ?? {}
|
|
202
|
+
schema.hide = true
|
|
203
|
+
break
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { schema, url }
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
await app.register(openApiScalar, opts)
|
|
211
|
+
|
|
212
|
+
return { apiByApiRoutes }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const openApiGateway = fp(openApiGatewayPlugin)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import Ajv from 'ajv'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { ValidationErrors } from './errors.js'
|
|
4
|
+
import { openApiConfigSchema } from './openapi-config-schema.js'
|
|
5
|
+
|
|
6
|
+
const ajv = new Ajv()
|
|
7
|
+
const ajvValidate = ajv.compile(openApiConfigSchema)
|
|
8
|
+
|
|
9
|
+
export async function loadOpenApiConfig (pathToConfig) {
|
|
10
|
+
const openApiConfigFile = await readFile(pathToConfig, 'utf-8')
|
|
11
|
+
const openApiConfig = JSON.parse(openApiConfigFile)
|
|
12
|
+
|
|
13
|
+
if (!ajvValidate(openApiConfig)) {
|
|
14
|
+
const validationErrors = ajvValidate.errors.map(err => {
|
|
15
|
+
return {
|
|
16
|
+
/* c8 ignore next 1 */
|
|
17
|
+
path: err.instancePath === '' ? '/' : err.instancePath,
|
|
18
|
+
message: err.message + ' ' + JSON.stringify(err.params)
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
throw new ValidationErrors(
|
|
22
|
+
validationErrors
|
|
23
|
+
.map(err => {
|
|
24
|
+
return err.message
|
|
25
|
+
})
|
|
26
|
+
.join('\n')
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return openApiConfig
|
|
31
|
+
}
|