Skip to content

Commit 41ff076

Browse files
Merge pull request #298 from pulsar-edit/v2-endpoint-declaration
Endpoint `v2` declaration
2 parents 07b95f4 + 2876352 commit 41ff076

File tree

6 files changed

+309
-33
lines changed

6 files changed

+309
-33
lines changed

src/buildContext.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// An ephemeral context that's built per every single request
2+
// Once tests stop relying on the structure of the `context.js` object
3+
// we can move this to be side-by-side of the original context
4+
const context = require("./context.js");
5+
6+
module.exports = (req, res, endpoint) => {
7+
8+
// Build parameters
9+
let params = {};
10+
for (const param in endpoint.params) {
11+
if (typeof endpoint.params[param] === "function") {
12+
params[param] = endpoint.params[param](context, req);
13+
} else {
14+
// TODO use a JSON-Schema validator to extract params
15+
}
16+
}
17+
18+
return {
19+
req: req,
20+
res: res,
21+
endpoint: endpoint,
22+
params: params,
23+
timecop: new Timecop(),
24+
...context,
25+
// Any items that need to overwrite original context keys should be put after
26+
// the spread operator
27+
callStack: new context.callStack(),
28+
query: require("./query_parameters/index.js")
29+
};
30+
};
31+
32+
class Timecop {
33+
constructor() {
34+
this.timetable = {};
35+
}
36+
37+
start(service) {
38+
this.timetable[service] = {
39+
start: performance.now(),
40+
end: undefined,
41+
duration: undefined
42+
};
43+
}
44+
45+
end(service) {
46+
if (!this.timetable[service]) {
47+
this.timetable[service] = {};
48+
this.timetable[service].start = 0;
49+
// Wildly incorrect date, more likely to be caught
50+
// rather than letting the time taken be 0ms
51+
}
52+
this.timetable[service].end = performance.now();
53+
this.timetable[service].duration = this.timetable[service].end - this.timetable[service].start;
54+
}
55+
56+
toHeader() {
57+
let str = "";
58+
59+
for (const service in this.timetable) {
60+
if (str.length > 0) {
61+
str = str + ", ";
62+
}
63+
64+
str = str + `${service};dur=${Number(this.timetable[service].duration).toFixed(2)}`;
65+
}
66+
67+
return str;
68+
}
69+
}

src/controllers/README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Controllers (endpoints)
2+
3+
Within this directory is the definition of each endpoint served by the Pulsar Package Registry Backend.
4+
5+
Each file represents an endpoint, named like `methodPathSubpath`.
6+
Which would translate something like `GET /api/package/:packageName` => `getPackagePackageName.js`.
7+
8+
Within the file is an object exported that defines the endpoint and all of it's key features.
9+
Which not only builds the actual endpoints that users interact with, it also builds the SwaggerUI documentation that's served alongside the site.
10+
11+
In an attempt to support the most flexibility in any future changes of this schema, there are multiple valid versions, allowing any changes to the schema to be subtle and peice-mealed as needed.
12+
13+
## Schema v1 (default)
14+
15+
If the top level `version` key is absent, or set to `1` then the endpoint is using the `v1` schema, which is defined below:
16+
17+
```js
18+
module.exports = {
19+
// version: 1,
20+
// endpointKind: "raw", // An optional endpointKind value, which if `raw` means once `logic` is called no further processing is done on the request.
21+
docs: {
22+
// Almost every key corresponds to SwaggerUIs schema
23+
summary: "A summary of the endpoint, used directly in the SwaggerUI documentation.",
24+
description: "Another SwaggerUI key for a more in-depth explanation.",
25+
responses: {
26+
// All intended responses of the endpoint
27+
200: {
28+
// The HTTP status followed by a description and a definition of the content within the response.
29+
// Refer to SwaggerUIs documentation.
30+
description: "",
31+
content: { "application/json": "$userObjectPrivate" }
32+
// ^^ A key difference is when defining the object, we can reference complex objects by appending a `$`
33+
}
34+
}
35+
},
36+
endpoint: {
37+
method: "GET", // The endpoint method
38+
paths: [""], // An array of exact endpoint paths, written in ExpressJS style
39+
rateLimit: "", // An enum supporting different rate limit values.
40+
successStatus: 200, // The HTTP status code returned on success
41+
options: {
42+
// Key-Value pairs of HTTP headers that should be returned on an `OPTIONS` request to the path.
43+
},
44+
params: {
45+
// Parameters that are invoked automatically to decode their value from the HTTP req.
46+
auth: (context, req) => {}
47+
}
48+
},
49+
// The following are methods that will be called automatically during the HTTP request lifecycle
50+
async preLogic(req, res, context) {}, // Called before the `logic` function. Helpful for modifying any request details
51+
async logic(params, context) {}, // The main logic function called for the endpoint
52+
async postLogic(req, res, context) {}, // Called right after the initial logic call.
53+
async postReturnHTTP(req, res, context, obj) {}, // Called after returning to the client, allowing for any computations the user shouldn't wait on. `obj` is the return of the `logic` call
54+
};
55+
```
56+
57+
## Schema v2
58+
59+
If the top level `version` key is set to `1` then the endpoint schema is defined as:
60+
61+
```js
62+
module.exports = {
63+
version: 2,
64+
docs: {}, // Identical to v1
65+
headers: {}, // Key-Value pairs of headers. Which will be applied during an `OPTIONS`
66+
// request, as well as automatically on every request to this path.
67+
endpoint: {
68+
method: "", // Same as v1
69+
path: "", // A string or array of strings, defining the path, again in ExpressJS syntax.
70+
},
71+
params: {
72+
auth: {
73+
// A JSON Schema object of the parameters schema, that will be decoded automatically.
74+
// Or if a function will retain backwards compatible behavior
75+
type: "string"
76+
}
77+
},
78+
async preLogic(ctx) {}, // Called with just the shared context
79+
async logic(ctx) {}, // Called with just the shared context
80+
async postLogic(ctx) {}, // Called with just the shared context
81+
async postHttp(ctx, obj) {}, // Called with the shared context, and the return obj of `logic`
82+
};
83+
```
84+
85+
As you may have noticed the biggest difference between v2 and v1 is that v2 only calls each method with a shared context variable. This shared context is built dynamically for each request and includes all details of the request within it, meaning we don't need specialized calls such as `preLogic` or `postLogic` (although they are still supported just in case).
86+
87+
Additionally, `v2` takes even more values defined in this schema and automatically injects them into the documentation and live requests, attempting to define even more of the request semantics as an object.
88+
89+
Lastly, in `v2` the value of a `header` key can begin with a `%` which means it's value will be replaced with the corresponding value from the shared context.
90+
91+
Such as `%timecop.toHeader` => `return ctx.timecop.toHeader();`.

src/controllers/getOwnersOwnerName.js

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
module.exports = {
2+
version: 2,
23
docs: {
34
summary: "List all packages published under a single owner.",
45
responses: {
@@ -10,15 +11,15 @@ module.exports = {
1011
},
1112
},
1213
},
14+
headers: {
15+
Allow: "GET",
16+
"X-Content-Type-Options": "nosniff",
17+
"Server-Timing": "%timecop.toHeader"
18+
},
1319
endpoint: {
1420
method: "GET",
15-
paths: ["/api/owners/:ownerName"],
16-
rateLimit: "generic",
17-
successStatus: 200,
18-
options: {
19-
Allow: "GET",
20-
"X-Content-Type-Options": "nosniff",
21-
},
21+
path: "/api/owners/:ownerName",
22+
rateLimit: "generic"
2223
},
2324
params: {
2425
page: (context, req) => {
@@ -35,37 +36,40 @@ module.exports = {
3536
},
3637
},
3738

38-
async logic(params, context) {
39-
const callStack = new context.callStack();
39+
async logic(ctx) {
4040

41-
const packages = await context.database.getSortedPackages(params);
41+
ctx.timecop.start("db");
42+
const packages = await ctx.database.getSortedPackages(ctx.params);
43+
ctx.timecop.end("db");
4244

43-
callStack.addCall("db.getSortedPackages", packages);
45+
ctx.callStack.addCall("db.getSortedPackages", packages);
4446

4547
if (!packages.ok) {
46-
const sso = new context.sso();
48+
const sso = new ctx.sso();
4749

48-
return sso.notOk().addContent(packages).assignCalls(callStack);
50+
return sso.notOk().addContent(packages).assignCalls(ctx.callStack);
4951
}
5052

51-
const packObjShort = await context.models.constructPackageObjectShort(
53+
ctx.timecop.start("construct");
54+
const packObjShort = await ctx.models.constructPackageObjectShort(
5255
packages.content
5356
);
5457

5558
const packArray = Array.isArray(packObjShort)
5659
? packObjShort
5760
: [packObjShort];
5861

59-
const ssoP = new context.ssoPaginate();
62+
const ssoP = new ctx.ssoPaginate();
6063

6164
ssoP.resultCount = packages.pagination.count;
6265
ssoP.totalPages = packages.pagination.total;
6366
ssoP.limit = packages.pagination.limit;
6467
ssoP.buildLink(
65-
`${context.config.server_url}/api/owners/${params.owner}`,
68+
`${ctx.config.server_url}/api/owners/${ctx.params.owner}`,
6669
packages.pagination.page,
67-
params
70+
ctx.params
6871
);
72+
ctx.timecop.end("construct");
6973

7074
return ssoP.isOk().addContent(packArray);
7175
},

src/setupEndpoints.js

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const { MemoryStore } = require("express-rate-limit");
44

55
const endpoints = require("./controllers/endpoints.js");
66
const context = require("./context.js");
7+
const buildContext = require("./buildContext.js");
78

89
const app = express();
910

@@ -45,6 +46,66 @@ app.set("trust proxy", true);
4546

4647
app.use("/swagger-ui", express.static("docs/swagger"));
4748

49+
const endpointHandlerV2 = async function (node, req, res) {
50+
const ctx = buildContext(req, res, node);
51+
52+
if (typeof node.preLogic === "function") {
53+
await node.preLogic(ctx);
54+
}
55+
56+
let obj; // Logic return
57+
try {
58+
obj = await node.logic(ctx);
59+
} catch(err) {
60+
// Main logic of endpoint has failed, return gracefully
61+
obj = new ctx.sso();
62+
obj.notOk()
63+
.addContent(err)
64+
.addMessage("An unexpected error has occurred.")
65+
.addShort("server_error");
66+
}
67+
68+
if (typeof node.postLogic === "function") {
69+
await node.postLogic(ctx);
70+
}
71+
72+
// Before letting SSO take over the HTTP return, lets add headers
73+
for (const header in node.headers) {
74+
if (node.headers[header].startsWith("%")) {
75+
// This is a replacement header value
76+
const headerFuncCall = node.headers[header].replace("%", "");
77+
78+
// Drill down keypath defined in the func call
79+
let headerCallCtx = ctx;
80+
const namespaces = headerFuncCall.split(".");
81+
let func = namespaces.pop();
82+
for (let i = 0; i < namespaces.length; i++) {
83+
headerCallCtx = headerCallCtx[namespaces[i]];
84+
}
85+
86+
let headerFuncResult;
87+
88+
if (typeof headerCallCtx[func] === "function") {
89+
headerFuncResult = headerCallCtx[func]();
90+
} else {
91+
console.log(`Couldn't locate value for header: Key: ${header}; Value: ${node.headers[header]}`);
92+
headerFuncResult = "";
93+
}
94+
95+
res.append(header, headerFuncResult);
96+
} else {
97+
res.append(header, node.headers[header]);
98+
}
99+
}
100+
obj.handleReturnHTTP(req, res, context);
101+
102+
if (typeof node.postReturnHTTP === "function") {
103+
await node.postReturnHTTP(ctx, obj);
104+
}
105+
106+
return;
107+
};
108+
48109
const endpointHandler = async function (node, req, res) {
49110
let params = {};
50111

@@ -97,9 +158,23 @@ const endpointHandler = async function (node, req, res) {
97158
const pathOptions = [];
98159

99160
for (const node of endpoints) {
100-
for (const path of node.endpoint.paths) {
101-
let limiter = genericLimit;
161+
let paths;
162+
163+
if (node.version === 2) {
164+
if (!Array.isArray(node.endpoint.path)) {
165+
paths = [node.endpoint.path];
166+
} else {
167+
paths = node.endpoint.path;
168+
}
169+
} else {
170+
// implict V1
171+
paths = node.endpoint.paths;
172+
}
102173

174+
for (const path of paths) {
175+
let limiter = genericLimit;
176+
// TODO should v2 endpoints determine ratelimit by strings like this?
177+
// Or by decoding a `RateLimit-Policy` header?
103178
if (node.endpoint.rateLimit === "auth") {
104179
limiter = authLimit;
105180
} else if (node.endpoint.rateLimit === "generic") {
@@ -108,28 +183,44 @@ for (const node of endpoints) {
108183

109184
if (!pathOptions.includes(path)) {
110185
app.options(path, genericLimit, async (req, res) => {
111-
res.header(node.endpoint.options);
186+
let headerObj;
187+
188+
if (node.version === 2) {
189+
headerObj = node.headers;
190+
// TODO handle header value replacements
191+
} else {
192+
// v1
193+
headerObj = node.endpoint.options;
194+
}
195+
196+
res.header(headerObj);
112197
res.sendStatus(204);
113198
return;
114199
});
115200

116201
pathOptions.push(path);
117202
}
118203

204+
let handlerFunc = endpointHandler;
205+
206+
if (node.version === 2) {
207+
handlerFunc = endpointHandlerV2;
208+
}
209+
119210
switch (node.endpoint.method) {
120211
case "GET":
121212
app.get(path, limiter, async (req, res) => {
122-
await endpointHandler(node, req, res);
213+
await handlerFunc(node, req, res);
123214
});
124215
break;
125216
case "POST":
126217
app.post(path, limiter, async (req, res) => {
127-
await endpointHandler(node, req, res);
218+
await handlerFunc(node, req, res);
128219
});
129220
break;
130221
case "DELETE":
131222
app.delete(path, limiter, async (req, res) => {
132-
await endpointHandler(node, req, res);
223+
await handlerFunc(node, req, res);
133224
});
134225
break;
135226
default:

0 commit comments

Comments
 (0)