From bdcedeb2d99d3de209c1eb8032fe7eda654283b4 Mon Sep 17 00:00:00 2001 From: fisnik Date: Wed, 22 Oct 2025 12:14:59 +0200 Subject: [PATCH 1/2] Add a DELETE /scopes endpoint that uses regex to delete scope entries --- README.md | 24 +++++ src/modify.js | 22 ++++ src/web-server.js | 69 +++++++++++++ test/scope-map.test.js | 228 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 343 insertions(+) diff --git a/README.md b/README.md index 776b612..a10448f 100644 --- a/README.md +++ b/README.md @@ -495,6 +495,30 @@ Example using cURL: curl -X DELETE localhost:5000/services/my-service ``` +#### DELETE /scopes?env=alpha&dry_run + +Delete scope entries using regex to match on keys that you wish to delete. A request body with the 'regexString' field is required. 'regexString' must be a non-empty string. Using the 'dry_run' query parameter will return the entries that will be deleted; not affecting the actual import map. Don't forget to escape backslash's in your regexString if they are to literally be apart of the regex... This is mandatory for JSON parsers. + +Example using HTTPie: + +```sh +http DELETE :5000/scopes env==alpha dry_run==true regexString='^\/module\/([^/]+)\/$' + +http DELETE :5000/scopes regexString='^\/modules\/([^/]+)\/$' +``` + +Example using cURL: + +```sh +curl -X DELETE "http://localhost:5000/scopes?env=alpha&dry_run=true" \ + -H "Content-Type: application/json" \ + -d '{"regexString": "^\/modules\/([^/]+)\/$"}' + +curl -X DELETE "http://localhost:5000/scopes \ + -H "Content-Type: application/json" \ + -d '{"regexString": "^\/modules\/([^/]+)\/$"}' +``` + ##### Special Chars This project uses URI encoding: [encode URI]. If you have any service with special chars like _@_, _/_, etc... you need to use It's corresponding UTF-8 encoding character. diff --git a/src/modify.js b/src/modify.js index cb205e5..2a8ed5e 100644 --- a/src/modify.js +++ b/src/modify.js @@ -4,6 +4,8 @@ const lock = new (require("rwlock"))(); const ioOperations = require("./io-operations.js"); const { getConfig } = require("./config"); +class NoMatchingScopesError extends Error {} + const isImportMap = () => { const format = getConfig().manifestFormat; if (format === "importmap") { @@ -178,6 +180,26 @@ exports.modifyService = function ( }); }; +exports.NoMatchingScopesError = NoMatchingScopesError; +exports.deleteScopes = function (env, regex) { + return modifyLock(env, (json) => { + // I'm expecting this to always maintain the order of the keys + // note: pure numeric keys will always sort themselves + const filteredScopes = Object.fromEntries( + Object.entries(json.scopes).filter(([scopeKey]) => !regex.test(scopeKey)) + ); + + if ( + Object.keys(filteredScopes).length === Object.keys(json.scopes).length + ) { + throw new NoMatchingScopesError(); + } + + json.scopes = filteredScopes; + return json; + }); +}; + exports.getEmptyManifest = getEmptyManifest; function sortObjectAlphabeticallyByKeys(unordered) { diff --git a/src/web-server.js b/src/web-server.js index 689f460..3fe7349 100644 --- a/src/web-server.js +++ b/src/web-server.js @@ -332,6 +332,75 @@ app.delete("/services/:serviceName", function (req, res) { }); }); +app.delete("/scopes", function (req, res) { + let body; + if (typeof req.body === "string") { + try { + body = JSON.parse(req.body); + } catch (e) { + return res.status(400).send("Invalid request body"); + } + } else { + body = req.body; + } + + if (body.regexString === undefined) { + return res + .status(400) + .send("The regex key in the request body must have a value"); + } + if (typeof body.regexString !== "string") { + return res.status(400).send("The regex value must be a string"); + } + if (body.regexString.length === 0) { + return res.status(400).send("The regex value must not be an empty string"); + } + + let regex; + + try { + regex = new RegExp(body.regexString); + } catch (e) { + return res.status(400).send("The regex value is not valid regex"); + } + + const env = getEnv(req); + const isDryRun = req.query.dry_run === "true" || req.query.dry_run === ""; + + if (isDryRun) { + return ioOperations + .readManifest(env) + .then((data) => { + let scopesToBeDeleted = Object.fromEntries( + Object.entries(JSON.parse(data).scopes).filter(([key]) => + regex.test(key) + ) + ); + res.send(scopesToBeDeleted); + }) + .catch((ex) => { + sendError( + res, + ex, + "Unexpected error while collecting the deleted scopes for this dry run" + ); + }); + } + + return modify + .deleteScopes(env, regex) + .then((data) => { + res.send(data); + }) + .catch((ex) => { + if (ex instanceof modify.NoMatchingScopesError) { + return res.status(404).send("No matching scopes found"); + } else { + sendError(res, ex, "Could not delete scope(s)"); + } + }); +}); + let server; if (process.env.NODE_ENV !== "test") { server = app.listen( diff --git a/test/scope-map.test.js b/test/scope-map.test.js index 6251aba..2ab6578 100644 --- a/test/scope-map.test.js +++ b/test/scope-map.test.js @@ -103,3 +103,231 @@ describe(`/import-map.json - Scopes`, () => { }); }); }); + +describe(`DELETE /scopes`, () => { + beforeEach(async () => { + const response = await request(app) + .patch("/import-map.json") + .query({ + skip_url_check: true, + }) + .set("accept", "json") + .send({ + imports: { + a: "https://cdn.com/a/3.1.1/a.js", + b: "https://cdn.com/b/1.2.5/b.js", + shared: "https://cdn.com/shared@22/shared.js", + }, + scopes: { + "/a/1.0.0/": { + shared: "https://cdn.com/shared@11/shared.js", + }, + "/a/2.0.0/": { + shared: "https://cdn.com/shared@11/shared.js", + }, + "/a/3.1.1/": { + shared: "https://cdn.com/shared@19/shared.js", + }, + }, + }) + .expect(200) + .expect("Content-Type", /json/); + + expect(response.body).toMatchObject({ + imports: { + a: "https://cdn.com/a/3.1.1/a.js", + b: "https://cdn.com/b/1.2.5/b.js", + shared: "https://cdn.com/shared@22/shared.js", + }, + scopes: { + "/a/1.0.0/": { + shared: "https://cdn.com/shared@11/shared.js", + }, + "/a/2.0.0/": { + shared: "https://cdn.com/shared@11/shared.js", + }, + "/a/3.1.1/": { + shared: "https://cdn.com/shared@19/shared.js", + }, + }, + }); + }); + + it(`Returns a 400 if there is no request body`, async () => { + await request(app) + .delete("/scopes") + .set("accept", "json") + .send() + .expect(400); + }); + + it(`Returns a 400 if the request body doesn't have a regexString key`, async () => { + await request(app) + .delete("/scopes") + .set("accept", "json") + .send({ + somethingInvalid: 123, + }) + .expect(400); + }); + + it(`Returns a 400 if the regexString is not a string`, async () => { + await request(app) + .delete("/scopes") + .set("accept", "json") + .send({ + regexString: 123, + }) + .expect(400); + }); + + it(`Returns a 400 if the regexString is an empty string`, async () => { + await request(app) + .delete("/scopes") + .set("accept", "json") + .send({ + regexString: "", + }) + .expect(400); + }); + + it(`Returns a 400 if the regexString is invalid`, async () => { + await request(app) + .delete("/scopes") + .set("accept", "json") + .send({ + regexString: "something[", + }) + .expect(400); + }); + + it(`Returns a 404 if the regex doesn't match any scopes`, async () => { + await request(app) + .delete("/scopes") + .set("accept", "json") + .send({ + regexString: "/c/[^/]+/", + }) + .expect(404); + }); + + it(`Returns a 404 if the provided env doesn't exist`, async () => { + await request(app) + .delete("/scopes") + .set("accept", "json") + .query({ env: "envDoesNotExist" }) + .send({ + regexString: "/c/[^/]+/", + }) + .expect(404); + }); + + it(`Returns an empty object if using the dry_run flag and the regex doesn't match any scopes`, async () => { + const response = await request(app) + .delete("/scopes") + .set("accept", "json") + .query({ dry_run: true }) + .send({ + regexString: "/c/[^/]+/", + }) + .expect(200) + .expect("Content-Type", /json/); + + expect(response.body).toMatchObject({}); + }); + + it(`Returns a map of the scopes that would be deleted when using the dry_run flag`, async () => { + const response = await request(app) + .delete("/scopes") + .set("accept", "json") + .query({ dry_run: true }) + .send({ + // matches scopes that contain /a/{any string except "3.1.1"}/ + regexString: "/a/(?!3\\.1\\.1/)[^/]+/", + }) + .expect(200) + .expect("Content-Type", /json/); + + expect(response.body).toMatchObject({ + "/a/1.0.0/": { + shared: "https://cdn.com/shared@11/shared.js", + }, + "/a/2.0.0/": { + shared: "https://cdn.com/shared@11/shared.js", + }, + }); + }); + + it(`Deletes the correct scopes and returns the newly updated import-map`, async () => { + const response = await request(app) + .delete("/scopes") + .set("accept", "json") + .send({ + // matches scopes that contain /a/{any string except "3.1.1"}/ + regexString: "/a/(?!3\\.1\\.1/)[^/]+/", + }) + .expect(200) + .expect("Content-Type", /json/); + + expect(response.body).toMatchObject({ + imports: { + a: "https://cdn.com/a/3.1.1/a.js", + b: "https://cdn.com/b/1.2.5/b.js", + shared: "https://cdn.com/shared@22/shared.js", + }, + scopes: { + "/a/3.1.1/": { + shared: "https://cdn.com/shared@19/shared.js", + }, + }, + }); + }); + + it(`Deleting scopes doesn't effect the original order of the scopes`, async () => { + // Add some additional scopes + await request(app) + .patch("/import-map.json") + .query({ + skip_url_check: true, + }) + .set("accept", "json") + .send({ + scopes: { + "/b/": { + shared: "https://cdn.com/shared@11/shared.js", + }, + "/c/": { + shared: "https://cdn.com/shared@11/shared.js", + }, + "/d/": { + shared: "https://cdn.com/shared@19/shared.js", + }, + }, + }) + .expect(200) + .expect("Content-Type", /json/); + // Delete /a/2.0.0/, inserted from the beforeEach + await request(app) + .delete("/scopes") + .set("accept", "json") + .send({ + regexString: "^/a/2.0.0/$", + }) + .expect(200) + .expect("Content-Type", /json/); + // Delete /c/ + const response = await request(app) + .delete("/scopes") + .set("accept", "json") + .send({ + regexString: "^/c/$", + }) + .expect(200) + .expect("Content-Type", /json/); + + const actualScopeKeys = Object.keys(response.body.scopes); + const expectedScopeKeys = ["/a/1.0.0/", "/a/3.1.1/", "/b/", "/d/"]; + + expect(actualScopeKeys).toEqual(expectedScopeKeys); + }); +}); From 99c77c7732acb24f5428c1df10ec83bdeca32693 Mon Sep 17 00:00:00 2001 From: fisnik Date: Wed, 22 Oct 2025 12:21:32 +0200 Subject: [PATCH 2/2] silence env not found error in test --- test/scope-map.test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/scope-map.test.js b/test/scope-map.test.js index 2ab6578..281baaf 100644 --- a/test/scope-map.test.js +++ b/test/scope-map.test.js @@ -105,7 +105,10 @@ describe(`/import-map.json - Scopes`, () => { }); describe(`DELETE /scopes`, () => { + let errorSpy; beforeEach(async () => { + errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + errorSpy.mockClear(); const response = await request(app) .patch("/import-map.json") .query({