@terreno/api 0.7.0 → 0.7.2
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/dist/__tests__/versionCheck.test.d.ts +1 -0
- package/dist/__tests__/versionCheck.test.js +263 -0
- package/dist/expressServer.js +2 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/models/versionConfig.d.ts +17 -0
- package/dist/models/versionConfig.js +66 -0
- package/dist/terrenoApp.js +2 -2
- package/dist/vendor/wesleytodd-openapi/index.d.ts +27 -0
- package/dist/vendor/wesleytodd-openapi/index.js +176 -0
- package/dist/vendor/wesleytodd-openapi/lib/convert-yaml.d.ts +2 -0
- package/dist/vendor/wesleytodd-openapi/lib/convert-yaml.js +13 -0
- package/dist/vendor/wesleytodd-openapi/lib/generate-doc.d.ts +2 -0
- package/dist/vendor/wesleytodd-openapi/lib/generate-doc.js +148 -0
- package/dist/vendor/wesleytodd-openapi/lib/layer-schema.d.ts +2 -0
- package/dist/vendor/wesleytodd-openapi/lib/layer-schema.js +12 -0
- package/dist/vendor/wesleytodd-openapi/lib/minimum-doc.d.ts +6 -0
- package/dist/vendor/wesleytodd-openapi/lib/minimum-doc.js +11 -0
- package/dist/vendor/wesleytodd-openapi/lib/ui.d.ts +1 -0
- package/dist/vendor/wesleytodd-openapi/lib/ui.js +37 -0
- package/dist/vendor/wesleytodd-openapi/lib/validate.d.ts +2 -0
- package/dist/vendor/wesleytodd-openapi/lib/validate.js +168 -0
- package/dist/versionCheckPlugin.d.ts +15 -0
- package/dist/versionCheckPlugin.js +106 -0
- package/package.json +9 -2
- package/src/__tests__/versionCheck.test.ts +132 -0
- package/src/expressServer.ts +1 -2
- package/src/index.ts +2 -0
- package/src/models/versionConfig.ts +92 -0
- package/src/terrenoApp.ts +1 -2
- package/src/vendor/wesleytodd-openapi/LICENSE +15 -0
- package/src/vendor/wesleytodd-openapi/index.js +189 -0
- package/src/vendor/wesleytodd-openapi/lib/convert-yaml.js +13 -0
- package/src/vendor/wesleytodd-openapi/lib/generate-doc.js +153 -0
- package/src/vendor/wesleytodd-openapi/lib/layer-schema.js +13 -0
- package/src/vendor/wesleytodd-openapi/lib/minimum-doc.js +12 -0
- package/src/vendor/wesleytodd-openapi/lib/ui.js +71 -0
- package/src/vendor/wesleytodd-openapi/lib/validate.js +152 -0
- package/src/versionCheckPlugin.ts +81 -0
- 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
|
+
});
|
package/src/expressServer.ts
CHANGED
|
@@ -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
|
+
}
|