From 6c6e1529a2b5d98bc11b27ee9ecdbc3d2d3a718f Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Tue, 30 Jul 2024 12:58:22 +0200 Subject: [PATCH] impr: use tsrest for presets endpoints (@fehmer) (#5675) !nuf --- .../__tests__/api/controllers/preset.spec.ts | 338 ++++++++++++++++++ backend/__tests__/dal/preset.spec.ts | 142 +++++--- backend/__tests__/utils/misc.spec.ts | 29 ++ backend/scripts/openapi.ts | 5 + backend/src/api/controllers/config.ts | 4 +- backend/src/api/controllers/preset.ts | 50 ++- backend/src/api/routes/docs.ts | 2 +- backend/src/api/routes/index.ts | 2 +- backend/src/api/routes/presets.ts | 95 ++--- backend/src/api/schemas/config-schema.ts | 152 -------- backend/src/dal/preset.ts | 41 +-- .../src/documentation/internal-swagger.json | 150 +------- backend/src/documentation/public-swagger.json | 2 +- backend/src/utils/misc.ts | 13 +- backend/src/utils/monkey-response.ts | 4 +- frontend/src/ts/ape/endpoints/index.ts | 2 - frontend/src/ts/ape/endpoints/presets.ts | 44 --- frontend/src/ts/ape/index.ts | 3 +- frontend/src/ts/ape/types/presets.d.ts | 10 - frontend/src/ts/db.ts | 4 +- frontend/src/ts/elements/account/pb-tables.ts | 2 +- frontend/src/ts/event-handlers/settings.ts | 4 +- frontend/src/ts/modals/edit-preset.ts | 31 +- frontend/src/ts/types/types.d.ts | 7 +- packages/contracts/src/index.ts | 2 + packages/contracts/src/presets.ts | 83 +++++ packages/contracts/src/schemas/api.ts | 4 +- packages/contracts/src/schemas/presets.ts | 18 + packages/contracts/src/schemas/util.ts | 6 + packages/shared-types/src/index.ts | 17 +- packages/shared-types/src/util.ts | 2 - 31 files changed, 691 insertions(+), 577 deletions(-) create mode 100644 backend/__tests__/api/controllers/preset.spec.ts delete mode 100644 backend/src/api/schemas/config-schema.ts delete mode 100644 frontend/src/ts/ape/endpoints/presets.ts delete mode 100644 frontend/src/ts/ape/types/presets.d.ts create mode 100644 packages/contracts/src/presets.ts create mode 100644 packages/contracts/src/schemas/presets.ts delete mode 100644 packages/shared-types/src/util.ts diff --git a/backend/__tests__/api/controllers/preset.spec.ts b/backend/__tests__/api/controllers/preset.spec.ts new file mode 100644 index 000000000..90cc7c7c4 --- /dev/null +++ b/backend/__tests__/api/controllers/preset.spec.ts @@ -0,0 +1,338 @@ +import request from "supertest"; +import app from "../../../src/app"; +import * as PresetDal from "../../../src/dal/preset"; +import { ObjectId } from "mongodb"; +const mockApp = request(app); + +describe("PresetController", () => { + describe("get presets", () => { + const getPresetsMock = vi.spyOn(PresetDal, "getPresets"); + + afterEach(() => { + getPresetsMock.mockReset(); + }); + + it("should get the users presets", async () => { + //GIVEN + const presetOne = { + _id: new ObjectId(), + uid: "123456789", + name: "test1", + config: { language: "english" }, + }; + const presetTwo = { + _id: new ObjectId(), + uid: "123456789", + name: "test2", + config: { language: "polish" }, + }; + + getPresetsMock.mockResolvedValue([presetOne, presetTwo]); + + //WHEN + const { body } = await mockApp + .get("/presets") + .set("authorization", "Uid 123456789") + .expect(200); + + //THEN + expect(body).toStrictEqual({ + message: "Presets retrieved", + data: [ + { + _id: presetOne._id.toHexString(), + name: "test1", + config: { language: "english" }, + }, + { + _id: presetTwo._id.toHexString(), + name: "test2", + config: { language: "polish" }, + }, + ], + }); + + expect(getPresetsMock).toHaveBeenCalledWith("123456789"); + }); + it("should return empty array if user has no presets", async () => { + //GIVEN + getPresetsMock.mockResolvedValue([]); + + //WHEN + const { body } = await mockApp + .get("/presets") + .set("authorization", "Uid 123456789") + .expect(200); + + //THEN + expect(body).toStrictEqual({ + message: "Presets retrieved", + data: [], + }); + + expect(getPresetsMock).toHaveBeenCalledWith("123456789"); + }); + }); + + describe("add preset", () => { + const addPresetMock = vi.spyOn(PresetDal, "addPreset"); + + afterEach(() => { + addPresetMock.mockReset(); + }); + + it("should add the users preset", async () => { + //GIVEN + addPresetMock.mockResolvedValue({ presetId: "1" }); + + //WHEN + const { body } = await mockApp + .post("/presets") + .set("authorization", "Uid 123456789") + .accept("application/json") + .send({ + name: "new", + config: { + language: "english", + tags: ["one", "two"], + }, + }) + .expect(200); + + //THEN + expect(body).toStrictEqual({ + message: "Preset created", + data: { presetId: "1" }, + }); + + expect(addPresetMock).toHaveBeenCalledWith("123456789", { + name: "new", + config: { language: "english", tags: ["one", "two"] }, + }); + }); + it("should not fail with emtpy config", async () => { + //GIVEN + + addPresetMock.mockResolvedValue({ presetId: "1" }); + + //WHEN + const { body } = await mockApp + .post("/presets") + .set("authorization", "Uid 123456789") + .accept("application/json") + .send({ name: "new", config: {} }) + .expect(200); + + //THEN + expect(body).toStrictEqual({ + message: "Preset created", + data: { presetId: "1" }, + }); + + expect(addPresetMock).toHaveBeenCalledWith("123456789", { + name: "new", + config: {}, + }); + }); + it("should fail with missing mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/presets") + .set("authorization", "Uid 123456789") + .accept("application/json") + .send({}) + .expect(422); + + expect(body).toStrictEqual({ + message: "Invalid request data schema", + validationErrors: [`"name" Required`, `"config" Required`], + }); + expect(addPresetMock).not.toHaveBeenCalled(); + }); + it("should not fail with invalid preset", async () => { + //WHEN + const { body } = await mockApp + .post("/presets") + .set("authorization", "Uid 123456789") + .accept("application/json") + .send({ + _id: "1", + name: "update", + extra: "extra", + config: { + extra: "extra", + autoSwitchTheme: "yes", + confidenceMode: "pretty", + }, + }) + .expect(422); + + //THEN + expect(body).toStrictEqual({ + message: "Invalid request data schema", + validationErrors: [ + `"config.autoSwitchTheme" Expected boolean, received string`, + `"config.confidenceMode" Invalid enum value. Expected 'off' | 'on' | 'max', received 'pretty'`, + `"config" Unrecognized key(s) in object: 'extra'`, + `Unrecognized key(s) in object: '_id', 'extra'`, + ], + }); + + expect(addPresetMock).not.toHaveBeenCalled(); + }); + }); + + describe("update preset", () => { + const editPresetMock = vi.spyOn(PresetDal, "editPreset"); + + afterEach(() => { + editPresetMock.mockReset(); + }); + + it("should update the users preset", async () => { + //GIVEN + editPresetMock.mockResolvedValue({} as any); + + //WHEN + const { body } = await mockApp + .patch("/presets") + .set("authorization", "Uid 123456789") + .accept("application/json") + .send({ + _id: "1", + name: "new", + config: { + language: "english", + tags: ["one", "two"], + }, + }) + .expect(200); + + //THEN + expect(body).toStrictEqual({ + message: "Preset updated", + data: null, + }); + + expect(editPresetMock).toHaveBeenCalledWith("123456789", { + _id: "1", + name: "new", + config: { language: "english", tags: ["one", "two"] }, + }); + }); + it("should not fail with emtpy config", async () => { + //GIVEN + + editPresetMock.mockResolvedValue({} as any); + + //WHEN + const { body } = await mockApp + .patch("/presets") + .set("authorization", "Uid 123456789") + .accept("application/json") + .send({ _id: "1", name: "new", config: {} }) + .expect(200); + + //THEN + expect(body).toStrictEqual({ + message: "Preset updated", + data: null, + }); + + expect(editPresetMock).toHaveBeenCalledWith("123456789", { + _id: "1", + name: "new", + config: {}, + }); + }); + it("should fail with missing mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .patch("/presets") + .set("authorization", "Uid 123456789") + .accept("application/json") + .send({}) + .expect(422); + + expect(body).toStrictEqual({ + message: "Invalid request data schema", + validationErrors: [ + `"_id" Required`, + `"name" Required`, + `"config" Required`, + ], + }); + expect(editPresetMock).not.toHaveBeenCalled(); + }); + it("should not fail with invalid preset", async () => { + //WHEN + const { body } = await mockApp + .patch("/presets") + .set("authorization", "Uid 123456789") + .accept("application/json") + .send({ + _id: "1", + name: "update", + extra: "extra", + config: { + extra: "extra", + autoSwitchTheme: "yes", + confidenceMode: "pretty", + }, + }) + .expect(422); + + //THEN + expect(body).toStrictEqual({ + message: "Invalid request data schema", + validationErrors: [ + `"config.autoSwitchTheme" Expected boolean, received string`, + `"config.confidenceMode" Invalid enum value. Expected 'off' | 'on' | 'max', received 'pretty'`, + `"config" Unrecognized key(s) in object: 'extra'`, + `Unrecognized key(s) in object: 'extra'`, + ], + }); + + expect(editPresetMock).not.toHaveBeenCalled(); + }); + }); + describe("delete config", () => { + const deletePresetMock = vi.spyOn(PresetDal, "removePreset"); + + afterEach(() => { + deletePresetMock.mockReset(); + }); + + it("should delete the users preset", async () => { + //GIVEN + deletePresetMock.mockResolvedValue(); + + //WHEN + + const { body } = await mockApp + .delete("/presets/1") + .set("authorization", "Uid 123456789") + .expect(200); + + //THEN + expect(body).toStrictEqual({ + message: "Preset deleted", + data: null, + }); + + expect(deletePresetMock).toHaveBeenCalledWith("123456789", "1"); + }); + it("should fail without preset _id", async () => { + //GIVEN + deletePresetMock.mockResolvedValue(); + + //WHEN + await mockApp + .delete("/presets/") + .set("authorization", "Uid 123456789") + .expect(404); + + expect(deletePresetMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/__tests__/dal/preset.spec.ts b/backend/__tests__/dal/preset.spec.ts index f360e936d..6e5a10f30 100644 --- a/backend/__tests__/dal/preset.spec.ts +++ b/backend/__tests__/dal/preset.spec.ts @@ -1,18 +1,23 @@ import { ObjectId } from "mongodb"; import * as PresetDal from "../../src/dal/preset"; import _ from "lodash"; -import { off } from "process"; describe("PresetDal", () => { describe("readPreset", () => { it("should read", async () => { //GIVEN const uid = new ObjectId().toHexString(); - const first = await PresetDal.addPreset(uid, "first", { ads: "sellout" }); - const second = await PresetDal.addPreset(uid, "second", { - ads: "result", + const first = await PresetDal.addPreset(uid, { + name: "first", + config: { ads: "sellout" }, }); - await PresetDal.addPreset("unknown", "unknown", {}); + const second = await PresetDal.addPreset(uid, { + name: "second", + config: { + ads: "result", + }, + }); + await PresetDal.addPreset("unknown", { name: "unknown", config: {} }); //WHEN const read = await PresetDal.getPresets(uid); @@ -43,24 +48,27 @@ describe("PresetDal", () => { //GIVEN const uid = new ObjectId().toHexString(); for (let i = 0; i < 10; i++) { - await PresetDal.addPreset(uid, "test", {} as any); + await PresetDal.addPreset(uid, { name: "test", config: {} }); } //WHEN / THEN expect(() => - PresetDal.addPreset(uid, "max", {} as any) + PresetDal.addPreset(uid, { name: "max", config: {} }) ).rejects.toThrowError("Too many presets"); }); it("should add preset", async () => { //GIVEN const uid = new ObjectId().toHexString(); for (let i = 0; i < 9; i++) { - await PresetDal.addPreset(uid, "test", {} as any); + await PresetDal.addPreset(uid, { name: "test", config: {} }); } //WHEN - const newPreset = await PresetDal.addPreset(uid, "new", { - ads: "sellout", + const newPreset = await PresetDal.addPreset(uid, { + name: "new", + config: { + ads: "sellout", + }, }); //THEN @@ -82,31 +90,44 @@ describe("PresetDal", () => { describe("editPreset", () => { it("should not fail if preset is unknown", async () => { - await PresetDal.editPreset( - "uid", - new ObjectId().toHexString(), - "new", - undefined - ); + await PresetDal.editPreset("uid", { + _id: new ObjectId().toHexString(), + name: "new", + config: {}, + }); }); + it("should edit", async () => { //GIVEN const uid = new ObjectId().toHexString(); const decoyUid = new ObjectId().toHexString(); const first = ( - await PresetDal.addPreset(uid, "first", { ads: "sellout" }) + await PresetDal.addPreset(uid, { + name: "first", + config: { ads: "sellout" }, + }) ).presetId; const second = ( - await PresetDal.addPreset(uid, "second", { - ads: "result", + await PresetDal.addPreset(uid, { + name: "second", + config: { + ads: "result", + }, }) ).presetId; const decoy = ( - await PresetDal.addPreset(decoyUid, "unknown", { ads: "result" }) + await PresetDal.addPreset(decoyUid, { + name: "unknown", + config: { ads: "result" }, + }) ).presetId; //WHEN - await PresetDal.editPreset(uid, first, "newName", { ads: "off" }); + await PresetDal.editPreset(uid, { + _id: first, + name: "newName", + config: { ads: "off" }, + }); //THEN const read = await PresetDal.getPresets(uid); @@ -143,37 +164,18 @@ describe("PresetDal", () => { //GIVEN const uid = new ObjectId().toHexString(); const first = ( - await PresetDal.addPreset(uid, "first", { ads: "sellout" }) + await PresetDal.addPreset(uid, { + name: "first", + config: { ads: "sellout" }, + }) ).presetId; - //WHEN undefined - await PresetDal.editPreset(uid, first, "newName", undefined); - expect(await PresetDal.getPresets(uid)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - _id: new ObjectId(first), - uid: uid, - name: "newName", - config: { ads: "sellout" }, - }), - ]) - ); - - //WHEN null - await PresetDal.editPreset(uid, first, "newName", null); - expect(await PresetDal.getPresets(uid)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - _id: new ObjectId(first), - uid: uid, - name: "newName", - config: { ads: "sellout" }, - }), - ]) - ); - //WHEN empty - await PresetDal.editPreset(uid, first, "newName", {}); + await PresetDal.editPreset(uid, { + _id: first, + name: "newName", + config: {}, + }); expect(await PresetDal.getPresets(uid)).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -190,11 +192,18 @@ describe("PresetDal", () => { const uid = new ObjectId().toHexString(); const decoyUid = new ObjectId().toHexString(); const first = ( - await PresetDal.addPreset(uid, "first", { ads: "sellout" }) + await PresetDal.addPreset(uid, { + name: "first", + config: { ads: "sellout" }, + }) ).presetId; //WHEN - await PresetDal.editPreset(decoyUid, first, "newName", { ads: "off" }); + await PresetDal.editPreset(decoyUid, { + _id: first, + name: "newName", + config: { ads: "off" }, + }); //THEN const read = await PresetDal.getPresets(uid); @@ -222,12 +231,20 @@ describe("PresetDal", () => { //GIVEN const uid = new ObjectId().toHexString(); const decoyUid = new ObjectId().toHexString(); - const first = (await PresetDal.addPreset(uid, "first", {})).presetId; + const first = ( + await PresetDal.addPreset(uid, { name: "first", config: {} }) + ).presetId; const second = ( - await PresetDal.addPreset(uid, "second", { ads: "result" }) + await PresetDal.addPreset(uid, { + name: "second", + config: { ads: "result" }, + }) ).presetId; const decoy = ( - await PresetDal.addPreset(decoyUid, "unknown", { ads: "result" }) + await PresetDal.addPreset(decoyUid, { + name: "unknown", + config: { ads: "result" }, + }) ).presetId; //WHEN @@ -262,7 +279,10 @@ describe("PresetDal", () => { const uid = new ObjectId().toHexString(); const decoyUid = new ObjectId().toHexString(); const first = ( - await PresetDal.addPreset(uid, "first", { ads: "sellout" }) + await PresetDal.addPreset(uid, { + name: "first", + config: { ads: "sellout" }, + }) ).presetId; //WHEN @@ -294,10 +314,16 @@ describe("PresetDal", () => { //GIVEN const uid = new ObjectId().toHexString(); const decoyUid = new ObjectId().toHexString(); - await PresetDal.addPreset(uid, "first", {}); - await PresetDal.addPreset(uid, "second", { ads: "result" }); + await PresetDal.addPreset(uid, { name: "first", config: {} }); + await PresetDal.addPreset(uid, { + name: "second", + config: { ads: "result" }, + }); const decoy = ( - await PresetDal.addPreset(decoyUid, "unknown", { ads: "result" }) + await PresetDal.addPreset(decoyUid, { + name: "unknown", + config: { ads: "result" }, + }) ).presetId; //WHEN diff --git a/backend/__tests__/utils/misc.spec.ts b/backend/__tests__/utils/misc.spec.ts index b3ab03aff..71c32cad5 100644 --- a/backend/__tests__/utils/misc.spec.ts +++ b/backend/__tests__/utils/misc.spec.ts @@ -621,4 +621,33 @@ describe("Misc Utils", () => { }); }); }); + + describe("replaceObjectIds", () => { + it("replaces objecIds with string", () => { + const fromDatabase = { + _id: new ObjectId(), + test: "test", + number: 1, + }; + const fromDatabase2 = { + _id: new ObjectId(), + test: "bob", + number: 2, + }; + expect( + misc.replaceObjectIds([fromDatabase, fromDatabase2]) + ).toStrictEqual([ + { + _id: fromDatabase._id.toHexString(), + test: "test", + number: 1, + }, + { + _id: fromDatabase2._id.toHexString(), + test: "bob", + number: 2, + }, + ]); + }); + }); }); diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index f2a0b8637..d0163f885 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -56,6 +56,11 @@ export function getOpenApi(): OpenAPIObject { "User specific configurations like test settings, theme or tags.", "x-displayName": "User configuration", }, + { + name: "presets", + description: "User specific configuration presets.", + "x-displayName": "User presets", + }, ], }, diff --git a/backend/src/api/controllers/config.ts b/backend/src/api/controllers/config.ts index 5ed7b926d..7ba725ed9 100644 --- a/backend/src/api/controllers/config.ts +++ b/backend/src/api/controllers/config.ts @@ -20,7 +20,7 @@ export async function saveConfig( await ConfigDAL.saveConfig(uid, config); - return new MonkeyResponse2("Config updated"); + return new MonkeyResponse2("Config updated", null); } export async function deleteConfig( @@ -29,5 +29,5 @@ export async function deleteConfig( const { uid } = req.ctx.decodedToken; await ConfigDAL.deleteConfig(uid); - return new MonkeyResponse2("Config deleted"); + return new MonkeyResponse2("Config deleted", null); } diff --git a/backend/src/api/controllers/preset.ts b/backend/src/api/controllers/preset.ts index 0bf4e0e2d..2253cc235 100644 --- a/backend/src/api/controllers/preset.ts +++ b/backend/src/api/controllers/preset.ts @@ -1,44 +1,56 @@ +import { + AddPresetRequest, + AddPresetResponse, + DeletePresetsParams, + GetPresetResponse, +} from "@monkeytype/contracts/presets"; import * as PresetDAL from "../../dal/preset"; -import { MonkeyResponse } from "../../utils/monkey-response"; +import { MonkeyResponse2 } from "../../utils/monkey-response"; +import { replaceObjectId } from "../../utils/misc"; +import { Preset } from "@monkeytype/contracts/schemas/presets"; export async function getPresets( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; - const data = await PresetDAL.getPresets(uid); - return new MonkeyResponse("Preset retrieved", data); + const data = (await PresetDAL.getPresets(uid)) + .map((preset) => ({ + ...preset, + uid: undefined, + })) + .map(replaceObjectId); + + return new MonkeyResponse2("Presets retrieved", data); } export async function addPreset( - req: MonkeyTypes.Request -): Promise { - const { name, config } = req.body; + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; - const data = await PresetDAL.addPreset(uid, name, config); + const data = await PresetDAL.addPreset(uid, req.body); - return new MonkeyResponse("Preset created", data); + return new MonkeyResponse2("Preset created", data); } export async function editPreset( - req: MonkeyTypes.Request -): Promise { - const { _id, name, config } = req.body; + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; - await PresetDAL.editPreset(uid, _id, name, config); + await PresetDAL.editPreset(uid, req.body); - return new MonkeyResponse("Preset updated"); + return new MonkeyResponse2("Preset updated", null); } export async function removePreset( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { presetId } = req.params; const { uid } = req.ctx.decodedToken; - await PresetDAL.removePreset(uid, presetId as string); + await PresetDAL.removePreset(uid, presetId); - return new MonkeyResponse("Preset deleted"); + return new MonkeyResponse2("Preset deleted", null); } diff --git a/backend/src/api/routes/docs.ts b/backend/src/api/routes/docs.ts index a2d5fc25c..6c02825a0 100644 --- a/backend/src/api/routes/docs.ts +++ b/backend/src/api/routes/docs.ts @@ -20,7 +20,7 @@ router.use("/v2/internal.json", (req, res) => { res.sendFile("api/openapi.json", { root }); }); -router.use("/v2/public", (req, res) => { +router.use(["/v2/public", "/v2/"], (req, res) => { res.sendFile("api/public.html", { root }); }); diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 6d54c4adf..1515ff0f7 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -43,7 +43,6 @@ const APP_START_TIME = Date.now(); const API_ROUTE_MAP = { "/users": users, "/results": results, - "/presets": presets, "/psas": psas, "/public": publicStats, "/leaderboards": leaderboards, @@ -57,6 +56,7 @@ const API_ROUTE_MAP = { const s = initServer(); const router = s.router(contract, { configs, + presets, }); export function addApiRoutes(app: Application): void { diff --git a/backend/src/api/routes/presets.ts b/backend/src/api/routes/presets.ts index c33ce3afe..a995cd977 100644 --- a/backend/src/api/routes/presets.ts +++ b/backend/src/api/routes/presets.ts @@ -1,74 +1,25 @@ -import joi from "joi"; -import { authenticateRequest } from "../../middlewares/auth"; -import * as PresetController from "../controllers/preset"; +import { presetsContract } from "@monkeytype/contracts/presets"; +import { initServer } from "@ts-rest/express"; import * as RateLimit from "../../middlewares/rate-limit"; -import configSchema from "../schemas/config-schema"; -import { Router } from "express"; -import { asyncHandler } from "../../middlewares/utility"; -import { validateRequest } from "../../middlewares/validation"; +import * as PresetController from "../controllers/preset"; +import { callController } from "../ts-rest-adapter"; -const router = Router(); - -const presetNameSchema = joi - .string() - .required() - .regex(/^[0-9a-zA-Z_-]+$/) - .max(16) - .messages({ - "string.pattern.base": "Invalid preset name", - "string.max": "Preset name exceeds maximum of 16 characters", - }); - -router.get( - "/", - authenticateRequest(), - RateLimit.presetsGet, - asyncHandler(PresetController.getPresets) -); - -router.post( - "/", - authenticateRequest(), - RateLimit.presetsAdd, - validateRequest({ - body: { - name: presetNameSchema, - config: configSchema.keys({ - tags: joi.array().items(joi.string().token().max(50)), - }), - }, - }), - asyncHandler(PresetController.addPreset) -); - -router.patch( - "/", - authenticateRequest(), - RateLimit.presetsEdit, - validateRequest({ - body: { - _id: joi.string().token().required(), - name: presetNameSchema, - config: configSchema - .keys({ - tags: joi.array().items(joi.string().token().max(50)), - }) - .allow(null), - }, - }), - asyncHandler(PresetController.editPreset) -); - -router.delete( - "/:presetId", - authenticateRequest(), - RateLimit.presetsRemove, - validateRequest({ - params: { - presetId: joi.string().token().required(), - }, - }), - asyncHandler(PresetController.removePreset) -); - -export default router; +const s = initServer(); +export default s.router(presetsContract, { + get: { + middleware: [RateLimit.presetsGet], + handler: async (r) => callController(PresetController.getPresets)(r), + }, + add: { + middleware: [RateLimit.presetsAdd], + handler: async (r) => callController(PresetController.addPreset)(r), + }, + save: { + middleware: [RateLimit.presetsEdit], + handler: async (r) => callController(PresetController.editPreset)(r), + }, + delete: { + middleware: [RateLimit.presetsRemove], + handler: async (r) => callController(PresetController.removePreset)(r), + }, +}); diff --git a/backend/src/api/schemas/config-schema.ts b/backend/src/api/schemas/config-schema.ts deleted file mode 100644 index 48ad6d07b..000000000 --- a/backend/src/api/schemas/config-schema.ts +++ /dev/null @@ -1,152 +0,0 @@ -import _ from "lodash"; -import joi from "joi"; - -const CARET_STYLES = [ - "off", - "default", - "underline", - "outline", - "block", - "carrot", - "banana", -]; -//TODO replaced, still used by presets -const CONFIG_SCHEMA = joi.object({ - theme: joi.string().max(50).token(), - themeLight: joi.string().max(50).token(), - themeDark: joi.string().max(50).token(), - autoSwitchTheme: joi.boolean(), - customTheme: joi.boolean(), - customThemeId: joi.string().min(0).max(24).token(), - customThemeColors: joi - .array() - .items(joi.string().pattern(/^#([\da-f]{3}){1,2}$/i)) - .length(10), - favThemes: joi.array().items(joi.string().max(50).token()), - showKeyTips: joi.boolean(), - smoothCaret: joi.string().valid("off", "slow", "medium", "fast"), - quickRestart: joi.string().valid("off", "tab", "esc", "enter"), - punctuation: joi.boolean(), - numbers: joi.boolean(), - words: joi.number().min(0), - time: joi.number().min(0), - mode: joi.string().valid("time", "words", "quote", "zen", "custom"), - quoteLength: joi.array().items(joi.number()), - language: joi - .string() - .max(50) - .pattern(/^[a-zA-Z0-9_+]+$/), - fontSize: joi.number().min(0), - freedomMode: joi.boolean(), - difficulty: joi.string().valid("normal", "expert", "master"), - blindMode: joi.boolean(), - quickEnd: joi.boolean(), - caretStyle: joi.string().valid(...CARET_STYLES), - paceCaretStyle: joi.string().valid(...CARET_STYLES), - flipTestColors: joi.boolean(), - layout: joi.string().max(50).token(), - funbox: joi - .string() - .max(100) - .regex(/[\w#]+/), - confidenceMode: joi.string().valid("off", "on", "max"), - indicateTypos: joi.string().valid("off", "below", "replace"), - timerStyle: joi.string().valid("off", "bar", "text", "mini"), - liveSpeedStyle: joi.string().valid("off", "text", "mini"), - liveAccStyle: joi.string().valid("off", "text", "mini"), - liveBurstStyle: joi.string().valid("off", "text", "mini"), - colorfulMode: joi.boolean(), - randomTheme: joi - .string() - .valid("off", "on", "fav", "light", "dark", "custom"), - timerColor: joi.string().valid("black", "sub", "text", "main"), - timerOpacity: joi.number().valid(0.25, 0.5, 0.75, 1), - stopOnError: joi.string().valid("off", "word", "letter"), - showAllLines: joi.boolean(), - keymapMode: joi.string().valid("off", "static", "react", "next"), - keymapStyle: joi - .string() - .valid( - "staggered", - "alice", - "matrix", - "split", - "split_matrix", - "steno", - "steno_matrix" - ), - keymapLegendStyle: joi - .string() - .valid("lowercase", "uppercase", "blank", "dynamic"), - keymapLayout: joi - .string() - .regex(/[\w\-_]+/) - .valid() - .max(50), - keymapShowTopRow: joi.string().valid("always", "layout", "never"), - fontFamily: joi - .string() - .max(50) - .regex(/^[a-zA-Z0-9_\-+.]+$/), - smoothLineScroll: joi.boolean(), - alwaysShowDecimalPlaces: joi.boolean(), - alwaysShowWordsHistory: joi.boolean(), - singleListCommandLine: joi.string().valid("manual", "on"), - capsLockWarning: joi.boolean(), - playSoundOnError: joi.string().valid("off", ..._.range(1, 5).map(_.toString)), - playSoundOnClick: joi.alternatives().try( - joi.boolean(), //todo remove soon - joi.string().valid("off", ..._.range(1, 16).map(_.toString)) - ), - soundVolume: joi.string().valid("0.1", "0.5", "1.0"), - startGraphsAtZero: joi.boolean(), - showOutOfFocusWarning: joi.boolean(), - paceCaret: joi - .string() - .valid("off", "average", "pb", "last", "daily", "custom"), - paceCaretCustomSpeed: joi.number().min(0), - repeatedPace: joi.boolean(), - accountChart: joi - .array() - .items(joi.string().valid("on", "off")) - .min(3) - .max(4) - .optional(), //replace min max with length 4 after a while - minWpm: joi.string().valid("off", "custom"), - minWpmCustomSpeed: joi.number().min(0), - highlightMode: joi - .string() - .valid( - "off", - "letter", - "word", - "next_word", - "next_two_words", - "next_three_words" - ), - tapeMode: joi.string().valid("off", "letter", "word"), - typingSpeedUnit: joi.string().valid("wpm", "cpm", "wps", "cps", "wph"), - enableAds: joi.string().valid("off", "on", "max"), - ads: joi.string().valid("off", "result", "on", "sellout"), - hideExtraLetters: joi.boolean(), - strictSpace: joi.boolean(), - minAcc: joi.string().valid("off", "custom"), - minAccCustom: joi.number().min(0), - monkey: joi.boolean(), - repeatQuotes: joi.string().valid("off", "typing"), - oppositeShiftMode: joi.string().valid("off", "on", "keymap"), - customBackground: joi.string().uri().allow(""), - customBackgroundSize: joi.string().valid("cover", "contain", "max"), - customBackgroundFilter: joi.array().items(joi.number()), - customLayoutfluid: joi.string().regex(/^[0-9a-zA-Z_#]+$/), - monkeyPowerLevel: joi.string().valid("off", "1", "2", "3", "4"), - minBurst: joi.string().valid("off", "fixed", "flex"), - minBurstCustomSpeed: joi.number().min(0), - burstHeatmap: joi.boolean(), - britishEnglish: joi.boolean(), - lazyMode: joi.boolean(), - showAverage: joi.string().valid("off", "speed", "acc", "both"), - maxLineWidth: joi.number().min(20).max(1000).allow(0), -}); - -export default CONFIG_SCHEMA; diff --git a/backend/src/dal/preset.ts b/backend/src/dal/preset.ts index fbeb3b530..4186a1289 100644 --- a/backend/src/dal/preset.ts +++ b/backend/src/dal/preset.ts @@ -1,14 +1,15 @@ import MonkeyError from "../utils/error"; import * as db from "../init/db"; import { ObjectId, type Filter, Collection, type WithId } from "mongodb"; -import { - ConfigPreset, - DBConfigPreset as SharedDBConfigPreset, -} from "@monkeytype/shared-types"; +import { Preset } from "@monkeytype/contracts/schemas/presets"; const MAX_PRESETS = 10; -type DBConfigPreset = MonkeyTypes.WithObjectId; +type DBConfigPreset = MonkeyTypes.WithObjectId< + Preset & { + uid: string; + } +>; function getPresetKeyFilter( uid: string, @@ -37,36 +38,32 @@ export async function getPresets(uid: string): Promise { export async function addPreset( uid: string, - name: string, - config: ConfigPreset + preset: Omit ): Promise { - const presets = await getPresets(uid); - if (presets.length >= MAX_PRESETS) { + const presets = await getPresetsCollection().countDocuments({ uid }); + + if (presets >= MAX_PRESETS) { throw new MonkeyError(409, "Too many presets"); } - const preset = await getPresetsCollection().insertOne({ + const result = await getPresetsCollection().insertOne({ + ...preset, _id: new ObjectId(), uid, - name, - config, }); return { - presetId: preset.insertedId.toHexString(), + presetId: result.insertedId.toHexString(), }; } -export async function editPreset( - uid: string, - presetId: string, - name: string, - config: ConfigPreset | null | undefined -): Promise { +export async function editPreset(uid: string, preset: Preset): Promise { + const config = preset.config; const presetUpdates = config !== undefined && config !== null && Object.keys(config).length > 0 - ? { name, config } - : { name }; - await getPresetsCollection().updateOne(getPresetKeyFilter(uid, presetId), { + ? { name: preset.name, config } + : { name: preset.name }; + + await getPresetsCollection().updateOne(getPresetKeyFilter(uid, preset._id), { $set: presetUpdates, }); } diff --git a/backend/src/documentation/internal-swagger.json b/backend/src/documentation/internal-swagger.json index 9e293508e..672c21aaa 100644 --- a/backend/src/documentation/internal-swagger.json +++ b/backend/src/documentation/internal-swagger.json @@ -1,7 +1,7 @@ { "swagger": "2.0", "info": { - "description": "These are the set of `internal` endpoints dedicated to the Monkeytype web client. Authentication for these endpoints requires a user account.", + "description": "These are the set of `internal` endpoints dedicated to the Monkeytype web client. Authentication for these endpoints requires a user account.\nNote: We are currently re-working our APIs. Some endpoints are documented at https://api.monkeytype.com/docs/v2/internal", "version": "1.0.0", "title": "Monkeytype", "termsOfService": "https://monkeytype.com/terms-of-service", @@ -27,14 +27,6 @@ "name": "psas", "description": "Public service announcements" }, - { - "name": "presets", - "description": "Preset data and related operations" - }, - { - "name": "configs", - "description": "User configuration data and related operations" - }, { "name": "ape-keys", "description": "ApeKey data and related operations" @@ -442,146 +434,6 @@ } } }, - "/presets": { - "get": { - "tags": ["presets"], - "summary": "Gets saved preset configurations", - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - }, - "post": { - "tags": ["presets"], - "summary": "Creates a preset configuration", - "parameters": [ - { - "in": "body", - "name": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "config": { - "type": "object" - } - } - } - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - }, - "patch": { - "tags": ["presets"], - "summary": "Updates an existing preset configuration", - "parameters": [ - { - "in": "body", - "name": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "config": { - "type": "object" - } - } - } - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/presets/{presetId}": { - "delete": { - "tags": ["presets"], - "summary": "Deletes a preset configuration", - "parameters": [ - { - "in": "path", - "name": "presetId", - "required": true, - "type": "string" - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/configs": { - "get": { - "tags": ["configs"], - "summary": "Gets the user's current configuration", - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - }, - "patch": { - "tags": ["configs"], - "summary": "Updates a user's configuration", - "parameters": [ - { - "in": "body", - "name": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "config": { - "type": "object" - } - } - } - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, "/ape-keys": { "get": { "tags": ["ape-keys"], diff --git a/backend/src/documentation/public-swagger.json b/backend/src/documentation/public-swagger.json index aadc66ea5..f77795b0e 100644 --- a/backend/src/documentation/public-swagger.json +++ b/backend/src/documentation/public-swagger.json @@ -1,7 +1,7 @@ { "swagger": "2.0", "info": { - "description": "Documentation for the public endpoints provided by the Monkeytype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY`\n\nThere is a rate limit of `30 requests per minute` across all endpoints with some endpoints being more strict. Rate limit rates are shared across all ape keys.", + "description": "Documentation for the public endpoints provided by the Monkeytype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY`\n\nThere is a rate limit of `30 requests per minute` across all endpoints with some endpoints being more strict. Rate limit rates are shared across all ape keys.\n\nNote: We are currently re-working our APIs. Some endpoints are documented at https://api.monkeytype.com/docs/v2/public", "version": "1.0.0", "title": "Monkeytype API", "termsOfService": "https://monkeytype.com/terms-of-service", diff --git a/backend/src/utils/misc.ts b/backend/src/utils/misc.ts index 2c49c4f8b..1ba942e92 100644 --- a/backend/src/utils/misc.ts +++ b/backend/src/utils/misc.ts @@ -310,7 +310,7 @@ export function isDevEnvironment(): boolean { /** * convert database object into api object * @param data database object with `_id: ObjectId` - * @returns api obkect with `id: string` + * @returns api object with `id: string` */ export function replaceObjectId( data: T @@ -321,3 +321,14 @@ export function replaceObjectId( } as T & { _id: string }; return result; } + +/** + * convert database objects into api objects + * @param data database objects with `_id: ObjectId` + * @returns api objects with `id: string` + */ +export function replaceObjectIds( + data: T[] +): (T & { _id: string })[] { + return data.map((it) => replaceObjectId(it)); +} diff --git a/backend/src/utils/monkey-response.ts b/backend/src/utils/monkey-response.ts index 377e1b34e..a5df02092 100644 --- a/backend/src/utils/monkey-response.ts +++ b/backend/src/utils/monkey-response.ts @@ -45,9 +45,9 @@ export class MonkeyResponse2 implements MonkeyResponseType, MonkeyDataAware { public message: string; - public data: T | null; + public data: T; - constructor(message: string, data: T | null = null) { + constructor(message: string, data: T) { this.message = message; this.data = data; } diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts index 2e356a541..b7bdb5801 100644 --- a/frontend/src/ts/ape/endpoints/index.ts +++ b/frontend/src/ts/ape/endpoints/index.ts @@ -1,5 +1,4 @@ import Leaderboards from "./leaderboards"; -import Presets from "./presets"; import Psas from "./psas"; import Quotes from "./quotes"; import Results from "./results"; @@ -11,7 +10,6 @@ import Dev from "./dev"; export default { Leaderboards, - Presets, Psas, Public, Quotes, diff --git a/frontend/src/ts/ape/endpoints/presets.ts b/frontend/src/ts/ape/endpoints/presets.ts deleted file mode 100644 index f5a726680..000000000 --- a/frontend/src/ts/ape/endpoints/presets.ts +++ /dev/null @@ -1,44 +0,0 @@ -const BASE_PATH = "/presets"; - -export default class Presets { - constructor(private httpClient: Ape.HttpClient) { - this.httpClient = httpClient; - } - - async get(): Ape.EndpointResponse { - return await this.httpClient.get(BASE_PATH); - } - - async add( - presetName: string, - configChanges: MonkeyTypes.ConfigChanges - ): Ape.EndpointResponse { - const payload = { - name: presetName, - config: configChanges, - }; - - return await this.httpClient.post(BASE_PATH, { payload }); - } - - async edit( - presetId: string, - presetName: string, - configChanges: MonkeyTypes.ConfigChanges - ): Ape.EndpointResponse { - const payload = { - _id: presetId, - name: presetName, - config: configChanges, - }; - - return await this.httpClient.patch(BASE_PATH, { payload }); - } - - async delete( - presetId: string - ): Ape.EndpointResponse { - const encoded = encodeURIComponent(presetId); - return await this.httpClient.delete(`${BASE_PATH}/${encoded}`); - } -} diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts index ecfca4584..68827fba9 100644 --- a/frontend/src/ts/ape/index.ts +++ b/frontend/src/ts/ape/index.ts @@ -3,6 +3,7 @@ import { buildHttpClient } from "./adapters/axios-adapter"; import { envConfig } from "../constants/env-config"; import { buildClient } from "./adapters/ts-rest-adapter"; import { configsContract } from "@monkeytype/contracts/configs"; +import { presetsContract } from "@monkeytype/contracts/presets"; const API_PATH = ""; const BASE_URL = envConfig.backendUrl; @@ -18,7 +19,7 @@ const Ape = { psas: new endpoints.Psas(httpClient), quotes: new endpoints.Quotes(httpClient), leaderboards: new endpoints.Leaderboards(httpClient), - presets: new endpoints.Presets(httpClient), + presets: buildClient(presetsContract, BASE_URL, 10_000), publicStats: new endpoints.Public(httpClient), apeKeys: new endpoints.ApeKeys(httpClient), configuration: new endpoints.Configuration(httpClient), diff --git a/frontend/src/ts/ape/types/presets.d.ts b/frontend/src/ts/ape/types/presets.d.ts deleted file mode 100644 index eeb90687b..000000000 --- a/frontend/src/ts/ape/types/presets.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -// for some reason when using the dot notaion, the types are not being recognized as used -declare namespace Ape.Presets { - type GetPresets = DBConfigPreset[]; - type PostPreset = { - presetId: string; - }; - type PatchPreset = null; - type DeltePreset = null; -} diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index 848d5c5c5..f7f01f8c6 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -92,14 +92,14 @@ export async function initSnapshot(): Promise< if (presetsResponse.status !== 200) { // eslint-disable-next-line @typescript-eslint/no-throw-literal throw { - message: `${presetsResponse.message} (presets)`, + message: `${presetsResponse.body.message} (presets)`, responseCode: presetsResponse.status, }; } const userData = userResponse.data; const configData = configResponse.body.data; - const presetsData = presetsResponse.data; + const presetsData = presetsResponse.body.data; if (userData === null) { // eslint-disable-next-line @typescript-eslint/no-throw-literal diff --git a/frontend/src/ts/elements/account/pb-tables.ts b/frontend/src/ts/elements/account/pb-tables.ts index 869d3dd5f..f550a673d 100644 --- a/frontend/src/ts/elements/account/pb-tables.ts +++ b/frontend/src/ts/elements/account/pb-tables.ts @@ -3,7 +3,7 @@ import { format as dateFormat } from "date-fns/format"; import Format from "../../utils/format"; import { PersonalBests } from "@monkeytype/shared-types/user"; import { Mode2 } from "@monkeytype/shared-types/config"; -import { StringNumber } from "@monkeytype/shared-types/util"; +import { StringNumber } from "@monkeytype/contracts/schemas/util"; function clearTables(isProfile: boolean): void { const source = isProfile ? "Profile" : "Account"; diff --git a/frontend/src/ts/event-handlers/settings.ts b/frontend/src/ts/event-handlers/settings.ts index a97704725..70c7d5ee8 100644 --- a/frontend/src/ts/event-handlers/settings.ts +++ b/frontend/src/ts/event-handlers/settings.ts @@ -35,7 +35,7 @@ settingsPage EditPresetPopup.show("add"); } else if (target.classList.contains("editButton")) { const presetid = target.parentElement?.getAttribute("data-id"); - const name = target.parentElement?.getAttribute("data-name"); + const name = target.parentElement?.getAttribute("data-display"); if ( presetid === undefined || name === undefined || @@ -53,7 +53,7 @@ settingsPage EditPresetPopup.show("edit", presetid, name); } else if (target.classList.contains("removeButton")) { const presetid = target.parentElement?.getAttribute("data-id"); - const name = target.parentElement?.getAttribute("data-name"); + const name = target.parentElement?.getAttribute("data-display"); if ( presetid === undefined || name === undefined || diff --git a/frontend/src/ts/modals/edit-preset.ts b/frontend/src/ts/modals/edit-preset.ts index c18fcc00f..894230bad 100644 --- a/frontend/src/ts/modals/edit-preset.ts +++ b/frontend/src/ts/modals/edit-preset.ts @@ -91,12 +91,14 @@ async function apply(): Promise { Loader.show(); if (action === "add") { - const response = await Ape.presets.add(presetName, configChanges); + const response = await Ape.presets.add({ + body: { name: presetName, config: configChanges }, + }); - if (response.status !== 200 || response.data === null) { + if (response.status !== 200 || response.body.data === null) { Notifications.add( "Failed to add preset: " + - response.message.replace(presetName, propPresetName), + response.body.message.replace(presetName, propPresetName), -1 ); } else { @@ -107,18 +109,20 @@ async function apply(): Promise { name: presetName, config: configChanges, display: propPresetName, - _id: response.data.presetId, + _id: response.body.data.presetId, } as MonkeyTypes.SnapshotPreset); } } else if (action === "edit") { - const response = await Ape.presets.edit( - presetId, - presetName, - configChanges - ); + const response = await Ape.presets.save({ + body: { + _id: presetId, + name: presetName, + config: configChanges, + }, + }); if (response.status !== 200) { - Notifications.add("Failed to edit preset: " + response.message, -1); + Notifications.add("Failed to edit preset: " + response.body.message, -1); } else { Notifications.add("Preset updated", 1); const preset = snapshotPresets.filter( @@ -131,10 +135,13 @@ async function apply(): Promise { } } } else if (action === "remove") { - const response = await Ape.presets.delete(presetId); + const response = await Ape.presets.delete({ params: { presetId } }); if (response.status !== 200) { - Notifications.add("Failed to remove preset: " + response.message, -1); + Notifications.add( + "Failed to remove preset: " + response.body.message, + -1 + ); } else { Notifications.add("Preset removed", 1); snapshotPresets.forEach( diff --git a/frontend/src/ts/types/types.d.ts b/frontend/src/ts/types/types.d.ts index 7d736fc3a..4e9e94d80 100644 --- a/frontend/src/ts/types/types.d.ts +++ b/frontend/src/ts/types/types.d.ts @@ -176,9 +176,10 @@ declare namespace MonkeyTypes { tags: string[]; } & import("@monkeytype/shared-types/config").Config; - type SnapshotPreset = import("@monkeytype/shared-types").DBConfigPreset & { - display: string; - }; + type SnapshotPreset = + import("@monkeytype/contracts/schemas/presets").Preset & { + display: string; + }; type RawCustomTheme = { name: string; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 088c399d0..55954b358 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,8 +1,10 @@ import { initContract } from "@ts-rest/core"; import { configsContract } from "./configs"; +import { presetsContract } from "./presets"; const c = initContract(); export const contract = c.router({ configs: configsContract, + presets: presetsContract, }); diff --git a/packages/contracts/src/presets.ts b/packages/contracts/src/presets.ts new file mode 100644 index 000000000..c100cf310 --- /dev/null +++ b/packages/contracts/src/presets.ts @@ -0,0 +1,83 @@ +import { initContract } from "@ts-rest/core"; +import { z } from "zod"; + +import { + CommonResponses, + EndpointMetadata, + MonkeyResponseSchema, + responseWithData, +} from "./schemas/api"; +import { PresetSchema } from "./schemas/presets"; +import { IdSchema } from "./schemas/util"; + +export const GetPresetResponseSchema = responseWithData(z.array(PresetSchema)); +export type GetPresetResponse = z.infer; + +export const AddPresetRequestSchema = PresetSchema.omit({ _id: true }); +export type AddPresetRequest = z.infer; + +export const AddPresetResponseSchemna = responseWithData( + z.object({ presetId: IdSchema }) +); +export type AddPresetResponse = z.infer; + +export const DeletePresetsParamsSchema = z.object({ + presetId: IdSchema, +}); +export type DeletePresetsParams = z.infer; + +const c = initContract(); + +export const presetsContract = c.router( + { + get: { + summary: "get presets", + description: "Get presets of the current user.", + method: "GET", + path: "/", + responses: { + 200: GetPresetResponseSchema, + }, + }, + add: { + summary: "add preset", + description: "Add a new preset for the current user.", + method: "POST", + path: "/", + body: AddPresetRequestSchema.strict(), + responses: { + 200: AddPresetResponseSchemna, + }, + }, + save: { + summary: "update preset", + description: "Update an existing preset for the current user.", + method: "PATCH", + path: "/", + body: PresetSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + delete: { + method: "DELETE", + path: "/:presetId", + pathParams: DeletePresetsParamsSchema, + body: c.noBody(), + responses: { + 200: MonkeyResponseSchema, + }, + summary: "delete preset", + description: "Delete preset by id.", + }, + }, + { + pathPrefix: "/presets", + strictStatusCodes: true, + metadata: { + openApiTags: "presets", + } as EndpointMetadata, + + commonResponses: CommonResponses, + } +); diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index f04555610..4e46fbbb4 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -1,11 +1,11 @@ import { z, ZodSchema } from "zod"; -export type OperationTag = "configs"; +export type OpenApiTag = "configs" | "presets"; export type EndpointMetadata = { /** Authentication options, by default a bearer token is required. */ authenticationOptions?: RequestAuthenticationOptions; - openApiTags?: OperationTag | OperationTag[]; + openApiTags?: OpenApiTag | OpenApiTag[]; }; export type RequestAuthenticationOptions = { diff --git a/packages/contracts/src/schemas/presets.ts b/packages/contracts/src/schemas/presets.ts new file mode 100644 index 000000000..8dea1dcaf --- /dev/null +++ b/packages/contracts/src/schemas/presets.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; +import { IdSchema, TagSchema } from "./util"; +import { PartialConfigSchema } from "./configs"; + +export const PresetNameSchema = z + .string() + .regex(/^[0-9a-zA-Z_-]+$/) + .max(16); +export type PresentName = z.infer; + +export const PresetSchema = z.object({ + _id: IdSchema, + name: PresetNameSchema, + config: PartialConfigSchema.extend({ + tags: z.array(TagSchema).optional(), + }), +}); +export type Preset = z.infer; diff --git a/packages/contracts/src/schemas/util.ts b/packages/contracts/src/schemas/util.ts index 743413404..74e5ed963 100644 --- a/packages/contracts/src/schemas/util.ts +++ b/packages/contracts/src/schemas/util.ts @@ -7,3 +7,9 @@ export const StringNumberSchema = z.custom<`${number}`>((val) => { export type StringNumber = z.infer; export const token = (): ZodString => z.string().regex(/^[a-zA-Z0-9_]+$/); + +export const IdSchema = token(); +export type Id = z.infer; + +export const TagSchema = token().max(50); +export type Tag = z.infer; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index f67434b7d..7085a3c8a 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1,8 +1,4 @@ -import { - Config, - Difficulty, - Mode, -} from "@monkeytype/contracts/schemas/configs"; +import { Difficulty, Mode } from "@monkeytype/contracts/schemas/configs"; import { Mode2 } from "./config"; import { PersonalBest, PersonalBests } from "./user"; @@ -329,17 +325,6 @@ export type ApeKey = { lastUsedOn: number; }; -export type ConfigPreset = Partial & { - tags?: string[]; -}; - -export type DBConfigPreset = { - _id: string; - uid: string; - name: string; - config: ConfigPreset; -}; - export type LeaderboardEntry = { _id: string; wpm: number; diff --git a/packages/shared-types/src/util.ts b/packages/shared-types/src/util.ts deleted file mode 100644 index 87c3cb8ac..000000000 --- a/packages/shared-types/src/util.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type StringNumber = - import("@monkeytype/contracts/schemas/util").StringNumber;