Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9919929
Migrate `deletePackagesPackageName` test
confused-Techie Dec 6, 2025
fa6d816
Add DB ability to delete a user
confused-Techie Dec 6, 2025
0f128c7
Remove old test, add cleanup
confused-Techie Dec 6, 2025
149c003
Migrate `getRoot` tests
confused-Techie Dec 6, 2025
135b4fc
Migrate `getUpdates`
confused-Techie Dec 6, 2025
89da693
Migrate `getUsers`
confused-Techie Dec 6, 2025
3cae102
Migrate `getOwnersOwnerName` test
confused-Techie Dec 6, 2025
8b7a9fe
Migrate `getUsersLogin`
confused-Techie Dec 6, 2025
4d5b3a7
Migrate `getThemesFeatured`
confused-Techie Dec 6, 2025
2474e38
Migrate `getLogin`
confused-Techie Dec 6, 2025
42e98ca
Migrate `getStars`
confused-Techie Dec 6, 2025
813ee31
Migrate `postPackagesPackageNameVersions`
confused-Techie Dec 6, 2025
a20bfb7
Migrate `postPackagesPackageNameStar`
confused-Techie Jan 13, 2026
4537844
Resolve bug discovered in `/api/owner/:ownerName` endpoint
confused-Techie Jan 13, 2026
735e354
Resolve Codacy warnings
confused-Techie Jan 14, 2026
8014000
Fix expected response code from removing a star
confused-Techie Jan 14, 2026
369be1b
Merge pull request #296 from pulsar-edit/migrate-tests
confused-Techie Jan 14, 2026
07b95f4
Merge branch 'main' into v1.2
confused-Techie Jan 14, 2026
66ea31f
Add support for v2 endpoints, w/ docs & new context
confused-Techie Jan 18, 2026
7ee9041
Add building parameters
confused-Techie Jan 18, 2026
98c7189
Fix header replacements
confused-Techie Jan 18, 2026
17feda6
Migrate `/api/owners/:ownerName` to v2 & add tests
confused-Techie Jan 18, 2026
2876352
Fix unit tests
confused-Techie Jan 18, 2026
41ff076
Merge pull request #298 from pulsar-edit/v2-endpoint-declaration
confused-Techie Jan 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/buildContext.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// An ephemeral context that's built per every single request
// Once tests stop relying on the structure of the `context.js` object
// we can move this to be side-by-side of the original context
const context = require("./context.js");

module.exports = (req, res, endpoint) => {

// Build parameters
let params = {};
for (const param in endpoint.params) {
if (typeof endpoint.params[param] === "function") {
params[param] = endpoint.params[param](context, req);
} else {
// TODO use a JSON-Schema validator to extract params
}
}

return {
req: req,
res: res,
endpoint: endpoint,
params: params,
timecop: new Timecop(),
...context,
// Any items that need to overwrite original context keys should be put after
// the spread operator
callStack: new context.callStack(),
query: require("./query_parameters/index.js")
};
};

class Timecop {
constructor() {
this.timetable = {};
}

start(service) {
this.timetable[service] = {
start: performance.now(),
end: undefined,
duration: undefined
};
}

end(service) {
if (!this.timetable[service]) {
this.timetable[service] = {};
this.timetable[service].start = 0;
// Wildly incorrect date, more likely to be caught
// rather than letting the time taken be 0ms
}
this.timetable[service].end = performance.now();
this.timetable[service].duration = this.timetable[service].end - this.timetable[service].start;
}

toHeader() {
let str = "";

for (const service in this.timetable) {
if (str.length > 0) {
str = str + ", ";
}

str = str + `${service};dur=${Number(this.timetable[service].duration).toFixed(2)}`;
}

return str;
}
}
91 changes: 91 additions & 0 deletions src/controllers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Controllers (endpoints)

Within this directory is the definition of each endpoint served by the Pulsar Package Registry Backend.

Each file represents an endpoint, named like `methodPathSubpath`.
Which would translate something like `GET /api/package/:packageName` => `getPackagePackageName.js`.

Within the file is an object exported that defines the endpoint and all of it's key features.
Which not only builds the actual endpoints that users interact with, it also builds the SwaggerUI documentation that's served alongside the site.

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.

## Schema v1 (default)

If the top level `version` key is absent, or set to `1` then the endpoint is using the `v1` schema, which is defined below:

```js
module.exports = {
// version: 1,
// endpointKind: "raw", // An optional endpointKind value, which if `raw` means once `logic` is called no further processing is done on the request.
docs: {
// Almost every key corresponds to SwaggerUIs schema
summary: "A summary of the endpoint, used directly in the SwaggerUI documentation.",
description: "Another SwaggerUI key for a more in-depth explanation.",
responses: {
// All intended responses of the endpoint
200: {
// The HTTP status followed by a description and a definition of the content within the response.
// Refer to SwaggerUIs documentation.
description: "",
content: { "application/json": "$userObjectPrivate" }
// ^^ A key difference is when defining the object, we can reference complex objects by appending a `$`
}
}
},
endpoint: {
method: "GET", // The endpoint method
paths: [""], // An array of exact endpoint paths, written in ExpressJS style
rateLimit: "", // An enum supporting different rate limit values.
successStatus: 200, // The HTTP status code returned on success
options: {
// Key-Value pairs of HTTP headers that should be returned on an `OPTIONS` request to the path.
},
params: {
// Parameters that are invoked automatically to decode their value from the HTTP req.
auth: (context, req) => {}
}
},
// The following are methods that will be called automatically during the HTTP request lifecycle
async preLogic(req, res, context) {}, // Called before the `logic` function. Helpful for modifying any request details
async logic(params, context) {}, // The main logic function called for the endpoint
async postLogic(req, res, context) {}, // Called right after the initial logic call.
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
};
```

## Schema v2

If the top level `version` key is set to `1` then the endpoint schema is defined as:

```js
module.exports = {
version: 2,
docs: {}, // Identical to v1
headers: {}, // Key-Value pairs of headers. Which will be applied during an `OPTIONS`
// request, as well as automatically on every request to this path.
endpoint: {
method: "", // Same as v1
path: "", // A string or array of strings, defining the path, again in ExpressJS syntax.
},
params: {
auth: {
// A JSON Schema object of the parameters schema, that will be decoded automatically.
// Or if a function will retain backwards compatible behavior
type: "string"
}
},
async preLogic(ctx) {}, // Called with just the shared context
async logic(ctx) {}, // Called with just the shared context
async postLogic(ctx) {}, // Called with just the shared context
async postHttp(ctx, obj) {}, // Called with the shared context, and the return obj of `logic`
};
```

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).

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.

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.

Such as `%timecop.toHeader` => `return ctx.timecop.toHeader();`.
40 changes: 22 additions & 18 deletions src/controllers/getOwnersOwnerName.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module.exports = {
version: 2,
docs: {
summary: "List all packages published under a single owner.",
responses: {
Expand All @@ -10,15 +11,15 @@ module.exports = {
},
},
},
headers: {
Allow: "GET",
"X-Content-Type-Options": "nosniff",
"Server-Timing": "%timecop.toHeader"
},
endpoint: {
method: "GET",
paths: ["/api/owners/:ownerName"],
rateLimit: "generic",
successStatus: 200,
options: {
Allow: "GET",
"X-Content-Type-Options": "nosniff",
},
path: "/api/owners/:ownerName",
rateLimit: "generic"
},
params: {
page: (context, req) => {
Expand All @@ -30,42 +31,45 @@ module.exports = {
direction: (context, req) => {
return context.query.direction(req);
},
ownerName: (context, req) => {
owner: (context, req) => {
return context.query.ownerName(req);
},
},

async logic(params, context) {
const callStack = new context.callStack();
async logic(ctx) {

const packages = await context.database.getSortedPackages(params);
ctx.timecop.start("db");
const packages = await ctx.database.getSortedPackages(ctx.params);
ctx.timecop.end("db");

callStack.addCall("db.getSortedPackages", packages);
ctx.callStack.addCall("db.getSortedPackages", packages);

if (!packages.ok) {
const sso = new context.sso();
const sso = new ctx.sso();

return sso.notOk().addContent(packages).assignCalls(callStack);
return sso.notOk().addContent(packages).assignCalls(ctx.callStack);
}

const packObjShort = await context.models.constructPackageObjectShort(
ctx.timecop.start("construct");
const packObjShort = await ctx.models.constructPackageObjectShort(
packages.content
);

const packArray = Array.isArray(packObjShort)
? packObjShort
: [packObjShort];

const ssoP = new context.ssoPaginate();
const ssoP = new ctx.ssoPaginate();

ssoP.resultCount = packages.pagination.count;
ssoP.totalPages = packages.pagination.total;
ssoP.limit = packages.pagination.limit;
ssoP.buildLink(
`${context.config.server_url}/api/owners/${params.owner}`,
`${ctx.config.server_url}/api/owners/${ctx.params.owner}`,
packages.pagination.page,
params
ctx.params
);
ctx.timecop.end("construct");

return ssoP.isOk().addContent(packArray);
},
Expand Down
1 change: 1 addition & 0 deletions src/database/_export.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const keys = [
"packageNameAvailability",
"removePackageByName",
"removePackageVersion",
"removeUserByID",
"updateDecrementStar",
"updateIncrementStar",
"updatePackageDecrementDownloadByName",
Expand Down
37 changes: 37 additions & 0 deletions src/database/removeUserByID.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* @async
* @function removeUserByID
* @desc Remove a user from the database providing their ID.
* @param {int} id - User ID
* @returns {object} A Server status Object.
*/

module.exports = {
safe: true,
exec: async (sql, id) => {
return await sql
.begin(async (sqlTrans) => {

const command = await sqlTrans`
DELETE FROM users
WHERE id = ${id}
`;

if (command.count === 0) {
throw "Failed to delete user";
}

return { ok: true, content: "Successfully delete user." };
})
.catch((err) => {
return typeof err === "string"
? { ok: false, content: err, short: "server_error" }
: {
ok: false,
content: "A generic error occurred while deleting the user.",
short: "server_error",
error: err
};
});
},
};
Loading