@terreno/api 0.6.0 → 0.7.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.
Files changed (41) hide show
  1. package/README.md +1 -5
  2. package/dist/__tests__/versionCheck.test.d.ts +1 -0
  3. package/dist/__tests__/versionCheck.test.js +263 -0
  4. package/dist/expressServer.js +2 -2
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +2 -0
  7. package/dist/models/versionConfig.d.ts +17 -0
  8. package/dist/models/versionConfig.js +66 -0
  9. package/dist/terrenoApp.js +2 -2
  10. package/dist/vendor/wesleytodd-openapi/index.d.ts +27 -0
  11. package/dist/vendor/wesleytodd-openapi/index.js +176 -0
  12. package/dist/vendor/wesleytodd-openapi/lib/convert-yaml.d.ts +2 -0
  13. package/dist/vendor/wesleytodd-openapi/lib/convert-yaml.js +13 -0
  14. package/dist/vendor/wesleytodd-openapi/lib/generate-doc.d.ts +2 -0
  15. package/dist/vendor/wesleytodd-openapi/lib/generate-doc.js +148 -0
  16. package/dist/vendor/wesleytodd-openapi/lib/layer-schema.d.ts +2 -0
  17. package/dist/vendor/wesleytodd-openapi/lib/layer-schema.js +12 -0
  18. package/dist/vendor/wesleytodd-openapi/lib/minimum-doc.d.ts +6 -0
  19. package/dist/vendor/wesleytodd-openapi/lib/minimum-doc.js +11 -0
  20. package/dist/vendor/wesleytodd-openapi/lib/ui.d.ts +1 -0
  21. package/dist/vendor/wesleytodd-openapi/lib/ui.js +37 -0
  22. package/dist/vendor/wesleytodd-openapi/lib/validate.d.ts +2 -0
  23. package/dist/vendor/wesleytodd-openapi/lib/validate.js +168 -0
  24. package/dist/versionCheckPlugin.d.ts +15 -0
  25. package/dist/versionCheckPlugin.js +106 -0
  26. package/package.json +10 -3
  27. package/src/__tests__/versionCheck.test.ts +132 -0
  28. package/src/expressServer.ts +1 -2
  29. package/src/index.ts +2 -0
  30. package/src/models/versionConfig.ts +92 -0
  31. package/src/terrenoApp.ts +1 -2
  32. package/src/vendor/wesleytodd-openapi/LICENSE +15 -0
  33. package/src/vendor/wesleytodd-openapi/index.js +189 -0
  34. package/src/vendor/wesleytodd-openapi/lib/convert-yaml.js +13 -0
  35. package/src/vendor/wesleytodd-openapi/lib/generate-doc.js +153 -0
  36. package/src/vendor/wesleytodd-openapi/lib/layer-schema.js +13 -0
  37. package/src/vendor/wesleytodd-openapi/lib/minimum-doc.js +12 -0
  38. package/src/vendor/wesleytodd-openapi/lib/ui.js +71 -0
  39. package/src/vendor/wesleytodd-openapi/lib/validate.js +152 -0
  40. package/src/versionCheckPlugin.ts +81 -0
  41. package/tsconfig.json +1 -1
@@ -0,0 +1,132 @@
1
+ import {afterEach, beforeEach, describe, expect, it} from "bun:test";
2
+ import supertest from "supertest";
3
+ import {VersionConfig} from "../models/versionConfig";
4
+ import {TerrenoApp} from "../terrenoApp";
5
+ import {setupDb, UserModel} from "../tests";
6
+ import {VersionCheckPlugin} from "../versionCheckPlugin";
7
+
8
+ describe("VersionCheckPlugin", () => {
9
+ let app: ReturnType<typeof supertest>;
10
+
11
+ beforeEach(async () => {
12
+ await setupDb();
13
+ await VersionConfig.deleteMany({});
14
+
15
+ const expressApp = new TerrenoApp({
16
+ skipListen: true,
17
+ userModel: UserModel as any,
18
+ })
19
+ .register(new VersionCheckPlugin())
20
+ .build();
21
+
22
+ app = supertest(expressApp);
23
+ });
24
+
25
+ afterEach(async () => {
26
+ await VersionConfig.deleteMany({});
27
+ });
28
+
29
+ it("returns ok when no VersionConfig exists", async () => {
30
+ const res = await app.get("/version-check").query({platform: "web", version: 100});
31
+ expect(res.status).toBe(200);
32
+ expect(res.body).toEqual({status: "ok"});
33
+ });
34
+
35
+ it("returns ok when version param is missing", async () => {
36
+ const res = await app.get("/version-check");
37
+ expect(res.status).toBe(200);
38
+ expect(res.body).toEqual({status: "ok"});
39
+ });
40
+
41
+ it("returns ok when version param is invalid", async () => {
42
+ const res = await app.get("/version-check").query({platform: "web", version: "invalid"});
43
+ expect(res.status).toBe(200);
44
+ expect(res.body).toEqual({status: "ok"});
45
+ });
46
+
47
+ it("returns ok when client version >= warning and required (web)", async () => {
48
+ await VersionConfig.create({
49
+ webRequiredVersion: 50,
50
+ webWarningVersion: 100,
51
+ });
52
+
53
+ const res = await app.get("/version-check").query({platform: "web", version: 150});
54
+ expect(res.status).toBe(200);
55
+ expect(res.body).toEqual({status: "ok"});
56
+ });
57
+
58
+ it("returns warning when client version < warning (web)", async () => {
59
+ await VersionConfig.create({
60
+ warningMessage: "Please update!",
61
+ webRequiredVersion: 50,
62
+ webWarningVersion: 100,
63
+ });
64
+
65
+ const res = await app.get("/version-check").query({platform: "web", version: 80});
66
+ expect(res.status).toBe(200);
67
+ expect(res.body.status).toBe("warning");
68
+ expect(res.body.message).toBe("Please update!");
69
+ });
70
+
71
+ it("returns required when client version < required (web)", async () => {
72
+ await VersionConfig.create({
73
+ requiredMessage: "Update required",
74
+ updateUrl: "https://example.com/update",
75
+ webRequiredVersion: 100,
76
+ webWarningVersion: 150,
77
+ });
78
+
79
+ const res = await app.get("/version-check").query({platform: "web", version: 50});
80
+ expect(res.status).toBe(200);
81
+ expect(res.body.status).toBe("required");
82
+ expect(res.body.message).toBe("Update required");
83
+ expect(res.body.updateUrl).toBe("https://example.com/update");
84
+ });
85
+
86
+ it("uses mobile thresholds when platform is mobile", async () => {
87
+ await VersionConfig.create({
88
+ mobileRequiredVersion: 200,
89
+ mobileWarningVersion: 250,
90
+ webRequiredVersion: 50,
91
+ webWarningVersion: 80,
92
+ });
93
+
94
+ const webRes = await app.get("/version-check").query({platform: "web", version: 100});
95
+ expect(webRes.body.status).toBe("ok");
96
+
97
+ const mobileRes = await app.get("/version-check").query({platform: "mobile", version: 100});
98
+ expect(mobileRes.body.status).toBe("required");
99
+ });
100
+
101
+ it("defaults to web when platform is invalid", async () => {
102
+ await VersionConfig.create({
103
+ webRequiredVersion: 100,
104
+ webWarningVersion: 150,
105
+ });
106
+
107
+ const res = await app.get("/version-check").query({platform: "invalid", version: 50});
108
+ expect(res.body.status).toBe("required");
109
+ });
110
+
111
+ it("version equal to threshold returns ok (not warning)", async () => {
112
+ await VersionConfig.create({
113
+ webRequiredVersion: 50,
114
+ webWarningVersion: 100,
115
+ });
116
+
117
+ const res = await app.get("/version-check").query({platform: "web", version: 100});
118
+ expect(res.status).toBe(200);
119
+ expect(res.body).toEqual({status: "ok"});
120
+ });
121
+
122
+ it("version equal to required returns warning not required", async () => {
123
+ await VersionConfig.create({
124
+ webRequiredVersion: 100,
125
+ webWarningVersion: 150,
126
+ });
127
+
128
+ const res = await app.get("/version-check").query({platform: "web", version: 100});
129
+ expect(res.status).toBe(200);
130
+ expect(res.body.status).toBe("warning");
131
+ });
132
+ });
@@ -1,5 +1,4 @@
1
1
  import * as Sentry from "@sentry/bun";
2
- import openapi from "@wesleytodd/openapi";
3
2
  import cors from "cors";
4
3
  import cron from "cron";
5
4
  import express, {type Router} from "express";
@@ -8,7 +7,6 @@ import cloneDeep from "lodash/cloneDeep";
8
7
  import onFinished from "on-finished";
9
8
  import passport from "passport";
10
9
  import qs from "qs";
11
-
12
10
  import type {ModelRouterOptions} from "./api";
13
11
  import {addAuthRoutes, addMeRoutes, setupAuth, type UserModel as UserMongooseModel} from "./auth";
14
12
  import {apiErrorMiddleware, apiUnauthorizedMiddleware} from "./errors";
@@ -17,6 +15,7 @@ import {type LoggingOptions, logger, setupLogging} from "./logger";
17
15
  import {sendToSlack} from "./notifiers";
18
16
  import {openApiCompatMiddleware, patchAppUse} from "./openApiCompat";
19
17
  import {openApiEtagMiddleware} from "./openApiEtag";
18
+ import openapi from "./vendor/wesleytodd-openapi/index";
20
19
 
21
20
  const SLOW_READ_MAX = 200;
22
21
  const SLOW_WRITE_MAX = 500;
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export * from "./expressServer";
10
10
  export * from "./githubAuth";
11
11
  export * from "./logger";
12
12
  export * from "./middleware";
13
+ export * from "./models/versionConfig";
13
14
  export * from "./notifiers";
14
15
  export * from "./openApiBuilder";
15
16
  export * from "./openApiCompat";
@@ -24,3 +25,4 @@ export * from "./terrenoApp";
24
25
  export * from "./terrenoPlugin";
25
26
  export * from "./transformers";
26
27
  export * from "./utils";
28
+ export * from "./versionCheckPlugin";
@@ -0,0 +1,92 @@
1
+ import mongoose, {type Document} from "mongoose";
2
+
3
+ import {type APIErrorConstructor} from "../errors";
4
+ import {createdUpdatedPlugin, findOneOrNone} from "../plugins";
5
+
6
+ export interface VersionConfigDocument extends mongoose.Document {
7
+ webWarningVersion: number;
8
+ webRequiredVersion: number;
9
+ mobileWarningVersion: number;
10
+ mobileRequiredVersion: number;
11
+ warningMessage: string;
12
+ requiredMessage: string;
13
+ updateUrl?: string;
14
+ created?: Date;
15
+ updated?: Date;
16
+ }
17
+
18
+ export interface VersionConfigModel extends mongoose.Model<VersionConfigDocument> {
19
+ findOneOrNone(
20
+ query: Record<string, any>,
21
+ errorArgs?: Partial<APIErrorConstructor>
22
+ ): Promise<(Document & VersionConfigDocument) | null>;
23
+ }
24
+
25
+ const versionConfigSchema = new mongoose.Schema<VersionConfigDocument>(
26
+ {
27
+ mobileRequiredVersion: {
28
+ default: 0,
29
+ description: "Build number at which mobile users are blocked from using the app",
30
+ min: 0,
31
+ type: Number,
32
+ },
33
+ mobileWarningVersion: {
34
+ default: 0,
35
+ description: "Build number at which mobile users see a warning toast",
36
+ min: 0,
37
+ type: Number,
38
+ },
39
+ requiredMessage: {
40
+ default: "This version is no longer supported. Please update to continue.",
41
+ description: "Message shown on the blocking screen",
42
+ type: String,
43
+ },
44
+ updateUrl: {
45
+ description:
46
+ "App store or download URL for mobile updates (optional, falls back to expo-updates)",
47
+ type: String,
48
+ },
49
+ warningMessage: {
50
+ default: "A new version is available. Please update for the best experience.",
51
+ description: "Message shown in the warning toast",
52
+ type: String,
53
+ },
54
+ webRequiredVersion: {
55
+ default: 0,
56
+ description: "Build number at which web users are blocked from using the app",
57
+ min: 0,
58
+ type: Number,
59
+ },
60
+ webWarningVersion: {
61
+ default: 0,
62
+ description: "Build number at which web users see a warning toast",
63
+ min: 0,
64
+ type: Number,
65
+ },
66
+ },
67
+ {strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
68
+ );
69
+
70
+ // Enforce singleton: only one VersionConfig document can exist.
71
+ // The _singleton field is always "config" (required, immutable, enum-constrained)
72
+ // and a unique index guarantees at most one document.
73
+ (versionConfigSchema as mongoose.Schema).add({
74
+ _singleton: {
75
+ default: "config",
76
+ description: "Sentinel field to enforce singleton via unique index",
77
+ enum: ["config"],
78
+ immutable: true,
79
+ required: true,
80
+ select: false,
81
+ type: String,
82
+ },
83
+ });
84
+ versionConfigSchema.index({_singleton: 1}, {unique: true});
85
+
86
+ versionConfigSchema.plugin(createdUpdatedPlugin);
87
+ versionConfigSchema.plugin(findOneOrNone);
88
+
89
+ export const VersionConfig = mongoose.model<VersionConfigDocument, VersionConfigModel>(
90
+ "VersionConfig",
91
+ versionConfigSchema
92
+ );
package/src/terrenoApp.ts CHANGED
@@ -1,9 +1,7 @@
1
1
  import * as Sentry from "@sentry/bun";
2
- import openapi from "@wesleytodd/openapi";
3
2
  import cors from "cors";
4
3
  import express from "express";
5
4
  import qs from "qs";
6
-
7
5
  import type {ModelRouterRegistration} from "./api";
8
6
  import {addAuthRoutes, addMeRoutes, setupAuth, type UserModel as UserMongooseModel} from "./auth";
9
7
  import {ConfigurationApp, type ConfigurationAppOptions} from "./configurationApp";
@@ -14,6 +12,7 @@ import {type LoggingOptions, logger, setupLogging} from "./logger";
14
12
  import {openApiCompatMiddleware, patchAppUse} from "./openApiCompat";
15
13
  import {openApiEtagMiddleware} from "./openApiEtag";
16
14
  import type {TerrenoPlugin} from "./terrenoPlugin";
15
+ import openapi from "./vendor/wesleytodd-openapi/index";
17
16
 
18
17
  type CorsOrigin =
19
18
  | string
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2018 Wes Todd <wes@wesleytodd.com>
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
12
+ SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
14
+ OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
15
+ CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,189 @@
1
+ // Vendored from https://github.com/wesleytodd/express-openapi (branch: express-5)
2
+ // Package: @wesleytodd/openapi v1.1.0
3
+ // License: ISC (see LICENSE)
4
+ // Vendored to allow local modifications and avoid external dependency drift.
5
+ 'use strict'
6
+ const httpErrors = require('http-errors')
7
+ const Router = require('router')
8
+ const SwaggerParser = require('swagger-parser')
9
+ const ui = require('./lib/ui')
10
+ const makeValidator = require('./lib/validate')
11
+ const { get: getSchema, set: setSchema } = require('./lib/layer-schema')
12
+ const minimumViableDocument = require('./lib/minimum-doc')
13
+ const generateDocument = require('./lib/generate-doc')
14
+ const defaultRoutePrefix = '/openapi'
15
+ const YAML = require('yaml')
16
+
17
+ module.exports = function ExpressOpenApi (_routePrefix, _doc, _opts) {
18
+ // Acceptable arguments:
19
+ // oapi()
20
+ // oapi('/path')
21
+ // oapi('/path', doc)
22
+ // oapi('/path', doc, opts)
23
+ // oapi(doc)
24
+ // oapi(doc, opts)
25
+ //
26
+ // The below logic is correct, but very hard to reason about
27
+ let routePrefix = _routePrefix || defaultRoutePrefix
28
+ let doc = _doc || minimumViableDocument
29
+ let opts = _opts || {}
30
+ if (typeof _routePrefix !== 'string') {
31
+ routePrefix = defaultRoutePrefix
32
+ doc = _routePrefix || minimumViableDocument
33
+ opts = _doc || {}
34
+ }
35
+
36
+ // We need to route a bit, seems a safe addition
37
+ // to use the express router in an express middleware
38
+ const router = new Router()
39
+
40
+ // Fully generate the doc on the first request
41
+ let isFirstRequest = true
42
+
43
+ // Where the magic happens
44
+ const middleware = function OpenApiMiddleware (req, res, next) {
45
+ if (isFirstRequest) {
46
+ middleware.document = generateDocument(middleware.document, req.app._router || req.app.router, opts.basePath)
47
+ isFirstRequest = false
48
+ }
49
+
50
+ router.handle(req, res, next)
51
+ }
52
+
53
+ // Expose the current document and prefix
54
+ middleware.routePrefix = routePrefix
55
+ middleware.document = generateDocument(doc, undefined, opts.basePath)
56
+ middleware.generateDocument = generateDocument
57
+ middleware.options = opts
58
+
59
+ // Add a path schema to the document
60
+ middleware.path = function (schema = {}) {
61
+ function schemaMiddleware (req, res, next) {
62
+ next()
63
+ }
64
+
65
+ setSchema(schemaMiddleware, schema)
66
+ return schemaMiddleware
67
+ }
68
+
69
+ // Validate path middleware
70
+ middleware.validPath = function (schema = {}, pathOpts = {}) {
71
+ let validate
72
+ function validSchemaMiddleware (req, res, next) {
73
+ if (!validate) {
74
+ validate = makeValidator(middleware, getSchema(validSchemaMiddleware), { ...pathOpts, ...opts })
75
+ }
76
+ return validate(req, res, next)
77
+ }
78
+
79
+ setSchema(validSchemaMiddleware, schema)
80
+ return validSchemaMiddleware
81
+ }
82
+
83
+ // Component definitions
84
+ middleware.component = function (type, name, description) {
85
+ if (!type) {
86
+ throw new TypeError('Component type is required')
87
+ }
88
+
89
+ // Return whole component type
90
+ if (!name && !description) {
91
+ return middleware.document.components && middleware.document.components[type]
92
+ }
93
+
94
+ // Return ref to type
95
+ if (name && !description) {
96
+ if (!middleware.document.components || !middleware.document.components[type] || !middleware.document.components[type][name]) {
97
+ throw new Error(`Unknown ${type} ref: ${name}`)
98
+ }
99
+ return { $ref: `#/components/${type}/${name}` }
100
+ }
101
+
102
+ // Set name on parameter if not passed
103
+ if (type === 'parameters') {
104
+ description.name = description.name || name
105
+ }
106
+
107
+ // Define a new component
108
+ middleware.document.components = middleware.document.components || {}
109
+ middleware.document.components[type] = middleware.document.components[type] || {}
110
+ middleware.document.components[type][name] = description
111
+
112
+ return middleware
113
+ }
114
+ middleware.schema = middleware.component.bind(null, 'schemas')
115
+ middleware.response = middleware.component.bind(null, 'responses')
116
+ middleware.parameters = middleware.component.bind(null, 'parameters')
117
+ middleware.examples = middleware.component.bind(null, 'examples')
118
+ middleware.requestBodies = middleware.component.bind(null, 'requestBodies')
119
+ middleware.headers = middleware.component.bind(null, 'headers')
120
+ middleware.securitySchemes = middleware.component.bind(null, 'securitySchemes')
121
+ middleware.links = middleware.component.bind(null, 'links')
122
+ middleware.callbacks = middleware.component.bind(null, 'callbacks')
123
+
124
+ // Expose ui middleware
125
+ middleware.swaggerui = (options) => ui.serveSwaggerUI(`${routePrefix}.json`, options)
126
+
127
+ // OpenAPI document as json
128
+ router.get(`${routePrefix}.json`, (req, res) => {
129
+ middleware.document = generateDocument(middleware.document, req.app._router || req.app.router, opts.basePath)
130
+ res.json(middleware.document)
131
+ })
132
+
133
+ // OpenAPI document as yaml
134
+ router.get([`${routePrefix}.yaml`, `${routePrefix}.yml`], (req, res) => {
135
+ const jsonSpec = generateDocument(middleware.document, req.app._router || req.app.router, opts.basePath)
136
+ const yamlSpec = YAML.stringify(jsonSpec)
137
+
138
+ res.type('yaml')
139
+ res.send(yamlSpec)
140
+ })
141
+
142
+ router.get(`${routePrefix}/components/:type/:name.json`, (req, res, next) => {
143
+ const { type, name } = req.params
144
+ middleware.document = generateDocument(middleware.document, req.app._router || req.app.router, opts.basePath)
145
+
146
+ // No component by that identifer
147
+ if (!middleware.document.components[type] || !middleware.document.components[type][name]) {
148
+ return next(httpErrors(404, `Component does not exist: ${type}/${name}`))
149
+ }
150
+
151
+ // Return component
152
+ res.json(middleware.document.components[type][name])
153
+ })
154
+
155
+ // Validate full open api document
156
+ router.get(`${routePrefix}/validate`, (req, res) => {
157
+ middleware.document = generateDocument(middleware.document, req.app._router || req.app.router, opts.basePath)
158
+ SwaggerParser.validate(middleware.document, (err, api) => {
159
+ if (err) {
160
+ return res.json({
161
+ valid: false,
162
+ details: err.details,
163
+ document: middleware.document
164
+ })
165
+ }
166
+ res.json({
167
+ valid: true,
168
+ document: middleware.document
169
+ })
170
+ })
171
+ })
172
+
173
+ // Serve up the for exploring the document
174
+ if (opts.htmlui) {
175
+ let ui = opts.htmlui
176
+ if (!Array.isArray(opts.htmlui)) {
177
+ ui = [opts.htmlui]
178
+ }
179
+ if (ui.includes('swagger-ui')) {
180
+ router.get(`${routePrefix}`, (req, res) => { res.redirect(`${routePrefix}/swagger-ui`) })
181
+ router.use(`${routePrefix}/swagger-ui`, middleware.swaggerui)
182
+ }
183
+ }
184
+
185
+ return middleware
186
+ }
187
+
188
+ module.exports.minimumViableDocument = minimumViableDocument
189
+ module.exports.defaultRoutePrefix = defaultRoutePrefix
@@ -0,0 +1,13 @@
1
+ // Vendored from https://github.com/wesleytodd/express-openapi (branch: express-5)
2
+ // License: ISC (see ../LICENSE)
3
+ const YAML = require('yaml')
4
+
5
+ /**
6
+ * Converts a json to yaml
7
+ * @param {object} jsonObject
8
+ * @returns {string} yamlString
9
+ */
10
+ module.exports = function (jsonObject) {
11
+ const doc = YAML.stringify(jsonObject)
12
+ return doc
13
+ }
@@ -0,0 +1,153 @@
1
+ // Vendored from https://github.com/wesleytodd/express-openapi (branch: main / tag: v1.1.0)
2
+ // NOTE: This file uses the main-branch algorithm (regexp-based route traversal) rather than
3
+ // the express-5 branch version, because the express-5 version requires router.getRoutes()
4
+ // which only exists on the bjohansebas/router fork and not on the Express 5 app router.
5
+ // The main-branch approach works correctly with the openApiCompat.ts regexp shim.
6
+ // License: ISC (see ../LICENSE)
7
+ 'use strict'
8
+ const pathToRegexp = require('path-to-regexp')
9
+ const minimumViableDocument = require('./minimum-doc')
10
+ const { get: getSchema, set: setSchema } = require('./layer-schema')
11
+
12
+ module.exports = function generateDocument (baseDocument, router, basePath) {
13
+ // Merge document with select minimum defaults
14
+ const doc = Object.assign({
15
+ openapi: minimumViableDocument.openapi
16
+ }, baseDocument, {
17
+ info: Object.assign({}, minimumViableDocument.info, baseDocument.info),
18
+ paths: Object.assign({}, minimumViableDocument.paths, baseDocument.paths)
19
+ })
20
+
21
+ // Iterate the middleware stack and add any paths and schemas, etc
22
+ router && router.stack.forEach((_layer) => {
23
+ iterateStack('', null, _layer, (path, routeLayer, layer) => {
24
+ if (basePath && path.startsWith(basePath)) {
25
+ path = path.replace(basePath, '')
26
+ }
27
+ const schema = getSchema(layer.handle)
28
+ if (!schema || !layer.method) {
29
+ return
30
+ }
31
+
32
+ const operation = Object.assign({}, schema)
33
+
34
+ // Add route params to schema
35
+ if (routeLayer && routeLayer.keys && routeLayer.keys.length) {
36
+ const keys = {}
37
+
38
+ const params = routeLayer.keys.map((k, i) => {
39
+ const prev = i > 0 && routeLayer.keys[i - 1]
40
+ // do not count parameters without a name if they are next to a named parameter
41
+ if (typeof k.name === 'number' && prev && prev.offset + prev.name.length + 1 >= k.offset) {
42
+ return null
43
+ }
44
+ let param
45
+ if (schema.parameters) {
46
+ param = schema.parameters.find((p) => p.name === k.name && p.in === 'path')
47
+ }
48
+
49
+ // Reformat the path
50
+ keys[k.name] = '{' + k.name + '}'
51
+
52
+ return Object.assign({
53
+ name: k.name,
54
+ in: 'path',
55
+ required: !k.optional,
56
+ schema: k.schema || { type: 'string' }
57
+ }, param || {})
58
+ })
59
+ .filter((e) => e)
60
+
61
+ if (schema.parameters) {
62
+ schema.parameters.forEach((p) => {
63
+ if (!params.find((pp) => p.name === pp.name)) {
64
+ params.push(p)
65
+ }
66
+ })
67
+ }
68
+
69
+ operation.parameters = params
70
+ path = pathToRegexp.compile(path.replace(/\*|\(\*\)/g, '(.*)'))(keys, { encode: (value) => value })
71
+ }
72
+
73
+ doc.paths[path] = doc.paths[path] || {}
74
+ doc.paths[path][layer.method] = operation
75
+ setSchema(layer.handle, operation)
76
+ })
77
+ })
78
+
79
+ return doc
80
+ }
81
+
82
+ function iterateStack (path, routeLayer, layer, cb) {
83
+ cb(path, routeLayer, layer)
84
+ if (layer.name === 'router') {
85
+ layer.handle.stack.forEach(l => {
86
+ path = path || ''
87
+ iterateStack(path + split(layer.regexp, layer.keys).join('/'), layer, l, cb)
88
+ })
89
+ }
90
+ if (!layer.route) {
91
+ return
92
+ }
93
+ if (Array.isArray(layer.route.path)) {
94
+ const r = layer.regexp.toString()
95
+ layer.route.path.forEach((p, i) => iterateStack(path + p, layer, {
96
+ ...layer,
97
+ // Checking if p is a string here since p may be a regex expression
98
+ keys: layer.keys.filter((k) => typeof p === 'string' ? p.includes(`/:${k.name}`) : false),
99
+ // There may be an issue here if the regex has a '|', but that seems to only be the case with user defined regex
100
+ regexp: new RegExp(`(${r.substring(2, r.length - 3).split('|')[i]})`),
101
+ route: { ...layer.route, path: '' }
102
+ }, cb))
103
+ return
104
+ }
105
+ layer.route.stack.forEach((l) => iterateStack(path + layer.route.path, layer, l, cb))
106
+ }
107
+
108
+ function processComplexMatch (thing, keys) {
109
+ let i = 0
110
+
111
+ return thing
112
+ .toString()
113
+ // The replace below replaces the regex used by Express to match dynamic parameters
114
+ // (i.e. /:id, /:name, etc...) with the name(s) of those parameter(s)
115
+ // This could have been accomplished with replaceAll for Node version 15 and above
116
+ // no-useless-escape is disabled since we need three backslashes
117
+ .replace(/\(\?\:\(\[\^\\\/\]\+\?\)\)/g, () => `{${keys[i++].name}}`) // eslint-disable-line no-useless-escape
118
+ .replace(/\\(.)/g, '$1')
119
+ // The replace below removes the regex used at the start of the string and
120
+ // the regex used to match the query parameters
121
+ .replace(/\/\^|\/\?(.*)/g, '')
122
+ .split('/')
123
+ }
124
+
125
+ // https://github.com/expressjs/express/issues/3308#issuecomment-300957572
126
+ function split (thing, keys) {
127
+ // In express v5 the router layers regexp (path-to-regexp@3.2.0)
128
+ // has some additional handling for end of lines, remove those
129
+ //
130
+ // layer.regexp
131
+ // v4 ^\\/sub-route\\/?(?=\\/|$)
132
+ // v5 ^\\/sub-route(?:\\/(?=$))?(?=\\/|$)
133
+ //
134
+ // l.regexp
135
+ // v4 ^\\/endpoint\\/?$
136
+ // v5 ^\\/endpoint(?:\\/)?$
137
+ if (typeof thing === 'string') {
138
+ return thing.split('/')
139
+ } else if (thing.fast_slash) {
140
+ return []
141
+ } else {
142
+ const match = thing
143
+ .toString()
144
+ .replace('\\/?', '')
145
+ .replace('(?=\\/|$)', '$')
146
+ // Added this line to catch the express v5 case after the v4 part is stripped off
147
+ .replace('(?:\\/(?=$))?$', '$')
148
+ .match(/^\/\^((?:\\[.*+?^${}()|[\]\\/]|[^.*+?^${}()|[\]\\/])*)\$\//)
149
+ return match
150
+ ? match[1].replace(/\\(.)/g, '$1').split('/')
151
+ : processComplexMatch(thing, keys)
152
+ }
153
+ }
@@ -0,0 +1,13 @@
1
+ // Vendored from https://github.com/wesleytodd/express-openapi (branch: express-5)
2
+ // License: ISC (see ../LICENSE)
3
+ 'use strict'
4
+ const schemas = new Map()
5
+
6
+ module.exports = {
7
+ set: (handler, schema) => {
8
+ schemas.set(handler, schema)
9
+ },
10
+ get: (handler) => {
11
+ return schemas.get(handler)
12
+ }
13
+ }
@@ -0,0 +1,12 @@
1
+ // Vendored from https://github.com/wesleytodd/express-openapi (branch: express-5)
2
+ // License: ISC (see ../LICENSE)
3
+ 'use strict'
4
+
5
+ module.exports = {
6
+ openapi: '3.0.0',
7
+ info: {
8
+ title: 'Express App',
9
+ version: '1.0.0'
10
+ },
11
+ paths: {}
12
+ }