impr: use tsrest for presets endpoints (@fehmer) (#5675)

!nuf
This commit is contained in:
Christian Fehmer 2024-07-30 12:58:22 +02:00 committed by GitHub
parent 3b29ad4b1d
commit 6c6e1529a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 691 additions and 577 deletions

View file

@ -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();
});
});
});

View file

@ -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

View file

@ -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,
},
]);
});
});
});

View file

@ -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",
},
],
},

View file

@ -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);
}

View file

@ -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<MonkeyResponse> {
req: MonkeyTypes.Request2
): Promise<GetPresetResponse> {
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<MonkeyResponse> {
const { name, config } = req.body;
req: MonkeyTypes.Request2<undefined, AddPresetRequest>
): Promise<AddPresetResponse> {
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<MonkeyResponse> {
const { _id, name, config } = req.body;
req: MonkeyTypes.Request2<undefined, Preset>
): Promise<MonkeyResponse2> {
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<MonkeyResponse> {
req: MonkeyTypes.Request2<undefined, undefined, DeletePresetsParams>
): Promise<MonkeyResponse2> {
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);
}

View file

@ -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 });
});

View file

@ -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 {

View file

@ -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),
},
});

View file

@ -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;

View file

@ -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<SharedDBConfigPreset>;
type DBConfigPreset = MonkeyTypes.WithObjectId<
Preset & {
uid: string;
}
>;
function getPresetKeyFilter(
uid: string,
@ -37,36 +38,32 @@ export async function getPresets(uid: string): Promise<DBConfigPreset[]> {
export async function addPreset(
uid: string,
name: string,
config: ConfigPreset
preset: Omit<Preset, "_id">
): Promise<PresetCreationResult> {
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<void> {
export async function editPreset(uid: string, preset: Preset): Promise<void> {
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,
});
}

View file

@ -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"],

View file

@ -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",

View file

@ -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<T extends { _id: ObjectId }>(
data: T
@ -321,3 +321,14 @@ export function replaceObjectId<T extends { _id: ObjectId }>(
} 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<T extends { _id: ObjectId }>(
data: T[]
): (T & { _id: string })[] {
return data.map((it) => replaceObjectId(it));
}

View file

@ -45,9 +45,9 @@ export class MonkeyResponse2<T = null>
implements MonkeyResponseType, MonkeyDataAware<T>
{
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;
}

View file

@ -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,

View file

@ -1,44 +0,0 @@
const BASE_PATH = "/presets";
export default class Presets {
constructor(private httpClient: Ape.HttpClient) {
this.httpClient = httpClient;
}
async get(): Ape.EndpointResponse<Ape.Presets.GetPresets> {
return await this.httpClient.get(BASE_PATH);
}
async add(
presetName: string,
configChanges: MonkeyTypes.ConfigChanges
): Ape.EndpointResponse<Ape.Presets.PostPreset> {
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<Ape.Presets.PatchPreset> {
const payload = {
_id: presetId,
name: presetName,
config: configChanges,
};
return await this.httpClient.patch(BASE_PATH, { payload });
}
async delete(
presetId: string
): Ape.EndpointResponse<Ape.Presets.DeltePreset> {
const encoded = encodeURIComponent(presetId);
return await this.httpClient.delete(`${BASE_PATH}/${encoded}`);
}
}

View file

@ -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),

View file

@ -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;
}

View file

@ -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

View file

@ -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";

View file

@ -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 ||

View file

@ -91,12 +91,14 @@ async function apply(): Promise<void> {
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<void> {
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<void> {
}
}
} 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(

View file

@ -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;

View file

@ -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,
});

View file

@ -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<typeof GetPresetResponseSchema>;
export const AddPresetRequestSchema = PresetSchema.omit({ _id: true });
export type AddPresetRequest = z.infer<typeof AddPresetRequestSchema>;
export const AddPresetResponseSchemna = responseWithData(
z.object({ presetId: IdSchema })
);
export type AddPresetResponse = z.infer<typeof AddPresetResponseSchemna>;
export const DeletePresetsParamsSchema = z.object({
presetId: IdSchema,
});
export type DeletePresetsParams = z.infer<typeof DeletePresetsParamsSchema>;
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,
}
);

View file

@ -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 = {

View file

@ -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<typeof PresetNameSchema>;
export const PresetSchema = z.object({
_id: IdSchema,
name: PresetNameSchema,
config: PartialConfigSchema.extend({
tags: z.array(TagSchema).optional(),
}),
});
export type Preset = z.infer<typeof PresetSchema>;

View file

@ -7,3 +7,9 @@ export const StringNumberSchema = z.custom<`${number}`>((val) => {
export type StringNumber = z.infer<typeof StringNumberSchema>;
export const token = (): ZodString => z.string().regex(/^[a-zA-Z0-9_]+$/);
export const IdSchema = token();
export type Id = z.infer<typeof IdSchema>;
export const TagSchema = token().max(50);
export type Tag = z.infer<typeof TagSchema>;

View file

@ -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<Config> & {
tags?: string[];
};
export type DBConfigPreset = {
_id: string;
uid: string;
name: string;
config: ConfigPreset;
};
export type LeaderboardEntry = {
_id: string;
wpm: number;

View file

@ -1,2 +0,0 @@
export type StringNumber =
import("@monkeytype/contracts/schemas/util").StringNumber;