impr: use tsrest for ape-keys endpoint (@fehmer) (#5706)

!nuf
This commit is contained in:
Christian Fehmer 2024-08-01 13:29:25 +02:00 committed by GitHub
parent a6912d20af
commit 8a09acd8d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 642 additions and 263 deletions

View file

@ -0,0 +1,422 @@
import request from "supertest";
import app from "../../../src/app";
import * as ApeKeyDal from "../../../src/dal/ape-keys";
import { ObjectId } from "mongodb";
import * as Configuration from "../../../src/init/configuration";
import * as UserDal from "../../../src/dal/user";
import _ from "lodash";
const mockApp = request(app);
const configuration = Configuration.getCachedConfiguration();
const uid = new ObjectId().toHexString();
describe("ApeKeyController", () => {
const getUserMock = vi.spyOn(UserDal, "getUser");
beforeEach(async () => {
await enableApeKeysEndpoints(true);
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: true }));
vi.useFakeTimers();
vi.setSystemTime(1000);
});
afterEach(() => {
getUserMock.mockReset();
vi.useRealTimers();
});
describe("get ape keys", () => {
const getApeKeysMock = vi.spyOn(ApeKeyDal, "getApeKeys");
afterEach(() => {
getApeKeysMock.mockReset();
});
it("should get the users config", async () => {
//GIVEN
const keyOne = apeKeyDb(uid);
const keyTwo = apeKeyDb(uid);
getApeKeysMock.mockResolvedValue([keyOne, keyTwo]);
//WHEN
const { body } = await mockApp
.get("/ape-keys")
.set("authorization", `Uid ${uid}`)
.expect(200);
//THEN
expect(body).toHaveProperty("message", "ApeKeys retrieved");
expect(body.data).toHaveProperty(keyOne._id.toHexString(), {
name: keyOne.name,
enabled: keyOne.enabled,
createdOn: keyOne.createdOn,
modifiedOn: keyOne.modifiedOn,
lastUsedOn: keyOne.lastUsedOn,
});
expect(body.data).toHaveProperty(keyTwo._id.toHexString(), {
name: keyTwo.name,
enabled: keyTwo.enabled,
createdOn: keyTwo.createdOn,
modifiedOn: keyTwo.modifiedOn,
lastUsedOn: keyTwo.lastUsedOn,
});
expect(body.data).keys([keyOne._id, keyTwo._id]);
expect(getApeKeysMock).toHaveBeenCalledWith(uid);
});
it("should fail if apeKeys endpoints are disabled", async () => {
//GIVEN
await enableApeKeysEndpoints(false);
//WHEN
const { body } = await mockApp
.get("/ape-keys")
.set("authorization", `Uid ${uid}`)
.expect(503);
//THEN
expect(body.message).toEqual("ApeKeys are currently disabled.");
});
it("should fail if user has no apeKey permissions", async () => {
//GIVEN
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
//WHEN
const { body } = await mockApp
.get("/ape-keys")
.set("authorization", `Uid ${uid}`)
.expect(403);
//THEN
expect(body.message).toEqual(
"You have lost access to ape keys, please contact support"
);
});
});
describe("add ape key", () => {
const addApeKeyMock = vi.spyOn(ApeKeyDal, "addApeKey");
const countApeKeysMock = vi.spyOn(ApeKeyDal, "countApeKeysForUser");
beforeEach(() => {
countApeKeysMock.mockResolvedValue(0);
});
afterEach(() => {
addApeKeyMock.mockReset();
countApeKeysMock.mockReset();
});
it("should add ape key", async () => {
//GIVEN
addApeKeyMock.mockResolvedValue("1");
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.set("authorization", `Uid ${uid}`)
.send({ name: "test", enabled: true })
.expect(200);
expect(body.message).toEqual("ApeKey generated");
expect(body.data).keys("apeKey", "apeKeyDetails", "apeKeyId");
expect(body.data.apeKey).not.toBeNull();
expect(body.data.apeKeyDetails).toStrictEqual({
createdOn: 1000,
enabled: true,
lastUsedOn: -1,
modifiedOn: 1000,
name: "test",
});
expect(body.data.apeKeyId).toEqual("1");
expect(addApeKeyMock).toHaveBeenCalledWith(
expect.objectContaining({
createdOn: 1000,
enabled: true,
lastUsedOn: -1,
modifiedOn: 1000,
name: "test",
uid: uid,
useCount: 0,
})
);
});
it("should fail without mandatory properties", async () => {
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.send({})
.set("authorization", `Uid ${uid}`)
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [`"name" Required`, `"enabled" Required`],
});
});
it("should fail with extra properties", async () => {
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.send({ name: "test", enabled: true, extra: "value" })
.set("authorization", `Uid ${uid}`)
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
});
});
it("should fail if max apeKeys is reached", async () => {
//GIVEN
countApeKeysMock.mockResolvedValue(1);
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(409);
//THEN
expect(body.message).toEqual(
"Maximum number of ApeKeys have been generated"
);
});
it("should fail if apeKeys endpoints are disabled", async () => {
//GIVEN
await enableApeKeysEndpoints(false);
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(503);
//THEN
expect(body.message).toEqual("ApeKeys are currently disabled.");
});
it("should fail if user has no apeKey permissions", async () => {
//GIVEN
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(403);
//THEN
expect(body.message).toEqual(
"You have lost access to ape keys, please contact support"
);
});
});
describe("edit ape key", () => {
const editApeKeyMock = vi.spyOn(ApeKeyDal, "editApeKey");
const apeKeyId = new ObjectId().toHexString();
afterEach(() => {
editApeKeyMock.mockReset();
});
it("should edit ape key", async () => {
//GIVEN
editApeKeyMock.mockResolvedValue();
//WHEN
const { body } = await mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "new", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(200);
//THEN
expect(body.message).toEqual("ApeKey updated");
expect(editApeKeyMock).toHaveBeenCalledWith(uid, apeKeyId, "new", false);
});
it("should edit ape key with single property", async () => {
//GIVEN
editApeKeyMock.mockResolvedValue();
//WHEN
const { body } = await mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "new" })
.set("authorization", `Uid ${uid}`)
.expect(200);
//THEN
expect(body.message).toEqual("ApeKey updated");
expect(editApeKeyMock).toHaveBeenCalledWith(
uid,
apeKeyId,
"new",
undefined
);
});
it("should fail with missing path", async () => {
//GIVEN
//WHEN
await mockApp
.patch(`/ape-keys/`)
.set("authorization", `Uid ${uid}`)
.expect(404);
});
it("should fail with extra properties", async () => {
//GIVEN
//WHEN
const { body } = await mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "new", extra: "value" })
.set("authorization", `Uid ${uid}`)
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
});
});
it("should fail if apeKeys endpoints are disabled", async () => {
//GIVEN
await enableApeKeysEndpoints(false);
//WHEN
const { body } = await mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(503);
//THEN
expect(body.message).toEqual("ApeKeys are currently disabled.");
});
it("should fail if user has no apeKey permissions", async () => {
//GIVEN
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
//WHEN
const { body } = await mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(403);
//THEN
expect(body.message).toEqual(
"You have lost access to ape keys, please contact support"
);
});
});
describe("delete ape key", () => {
const deleteApeKeyMock = vi.spyOn(ApeKeyDal, "deleteApeKey");
const apeKeyId = new ObjectId().toHexString();
afterEach(() => {
deleteApeKeyMock.mockReset();
});
it("should delete ape key", async () => {
//GIVEN
deleteApeKeyMock.mockResolvedValue();
//WHEN
const { body } = await mockApp
.delete(`/ape-keys/${apeKeyId}`)
.set("authorization", `Uid ${uid}`)
.expect(200);
//THEN
expect(body.message).toEqual("ApeKey deleted");
expect(deleteApeKeyMock).toHaveBeenCalledWith(uid, apeKeyId);
});
it("should fail with missing path", async () => {
//GIVEN
//WHEN
await mockApp
.delete(`/ape-keys/`)
.set("authorization", `Uid ${uid}`)
.expect(404);
});
it("should fail if apeKeys endpoints are disabled", async () => {
//GIVEN
await enableApeKeysEndpoints(false);
//WHEN
const { body } = await mockApp
.delete(`/ape-keys/${apeKeyId}`)
.set("authorization", `Uid ${uid}`)
.expect(503);
//THEN
expect(body.message).toEqual("ApeKeys are currently disabled.");
});
it("should fail if user has no apeKey permissions", async () => {
//GIVEN
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
//WHEN
const { body } = await mockApp
.delete(`/ape-keys/${apeKeyId}`)
.set("authorization", `Uid ${uid}`)
.expect(403);
//THEN
expect(body.message).toEqual(
"You have lost access to ape keys, please contact support"
);
});
});
});
function apeKeyDb(
uid: string,
data?: Partial<MonkeyTypes.ApeKeyDB>
): MonkeyTypes.ApeKeyDB {
return {
_id: new ObjectId(),
uid,
hash: "hash",
useCount: 1,
name: "name",
enabled: true,
createdOn: Math.random() * Date.now(),
lastUsedOn: Math.random() * Date.now(),
modifiedOn: Math.random() * Date.now(),
...data,
};
}
async function enableApeKeysEndpoints(enabled: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
apeKeys: { endpointsEnabled: enabled, maxKeysPerUser: 1 },
});
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig
);
}
function user(
uid: string,
data: Partial<MonkeyTypes.DBUser>
): MonkeyTypes.DBUser {
return {
uid,
...data,
} as MonkeyTypes.DBUser;
}

View file

@ -61,6 +61,11 @@ export function getOpenApi(): OpenAPIObject {
description: "User specific configuration presets.",
"x-displayName": "User presets",
},
{
name: "ape-keys",
description: "Ape keys provide access to certain API endpoints.",
"x-displayName": "Ape Keys",
},
],
},

View file

@ -3,29 +3,37 @@ import { randomBytes } from "crypto";
import { hash } from "bcrypt";
import * as ApeKeysDAL from "../../dal/ape-keys";
import MonkeyError from "../../utils/error";
import { MonkeyResponse } from "../../utils/monkey-response";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { base64UrlEncode } from "../../utils/misc";
import { ObjectId } from "mongodb";
import { ApeKey } from "@monkeytype/shared-types";
import {
AddApeKeyRequest,
AddApeKeyResponse,
ApeKeyParams,
EditApeKeyRequest,
GetApeKeyResponse,
} from "@monkeytype/contracts/ape-keys";
import { ApeKey } from "@monkeytype/contracts/schemas/ape-keys";
function cleanApeKey(apeKey: MonkeyTypes.ApeKeyDB): ApeKey {
return _.omit(apeKey, "hash", "_id", "uid", "useCount");
}
export async function getApeKeys(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2
): Promise<GetApeKeyResponse> {
const { uid } = req.ctx.decodedToken;
const apeKeys = await ApeKeysDAL.getApeKeys(uid);
const cleanedKeys = _(apeKeys).keyBy("_id").mapValues(cleanApeKey).value();
return new MonkeyResponse("ApeKeys retrieved", cleanedKeys);
return new MonkeyResponse2("ApeKeys retrieved", cleanedKeys);
}
export async function generateApeKey(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<undefined, AddApeKeyRequest>
): Promise<AddApeKeyResponse> {
const { name, enabled } = req.body;
const { uid } = req.ctx.decodedToken;
const { maxKeysPerUser, apeKeyBytes, apeKeySaltRounds } =
@ -54,7 +62,7 @@ export async function generateApeKey(
const apeKeyId = await ApeKeysDAL.addApeKey(apeKey);
return new MonkeyResponse("ApeKey generated", {
return new MonkeyResponse2("ApeKey generated", {
apeKey: base64UrlEncode(`${apeKeyId}.${apiKey}`),
apeKeyId,
apeKeyDetails: cleanApeKey(apeKey),
@ -62,24 +70,24 @@ export async function generateApeKey(
}
export async function editApeKey(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<undefined, EditApeKeyRequest, ApeKeyParams>
): Promise<MonkeyResponse2> {
const { apeKeyId } = req.params;
const { name, enabled } = req.body;
const { uid } = req.ctx.decodedToken;
await ApeKeysDAL.editApeKey(uid, apeKeyId as string, name, enabled);
await ApeKeysDAL.editApeKey(uid, apeKeyId, name, enabled);
return new MonkeyResponse("ApeKey updated");
return new MonkeyResponse2("ApeKey updated", null);
}
export async function deleteApeKey(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<undefined, undefined, ApeKeyParams>
): Promise<MonkeyResponse2> {
const { apeKeyId } = req.params;
const { uid } = req.ctx.decodedToken;
await ApeKeysDAL.deleteApeKey(uid, apeKeyId as string);
await ApeKeysDAL.deleteApeKey(uid, apeKeyId);
return new MonkeyResponse("ApeKey deleted");
return new MonkeyResponse2("ApeKey deleted", null);
}

View file

@ -1,91 +1,42 @@
import joi from "joi";
import { Router } from "express";
import { authenticateRequest } from "../../middlewares/auth";
import * as ApeKeyController from "../controllers/ape-key";
import { apeKeysContract } from "@monkeytype/contracts/ape-keys";
import { initServer } from "@ts-rest/express";
import * as RateLimit from "../../middlewares/rate-limit";
import * as ApeKeyController from "../controllers/ape-key";
import { callController } from "../ts-rest-adapter";
import { checkUserPermissions } from "../../middlewares/permission";
import { validate } from "../../middlewares/configuration";
import { asyncHandler } from "../../middlewares/utility";
import { validateRequest } from "../../middlewares/validation";
const apeKeyNameSchema = joi
.string()
.regex(/^[0-9a-zA-Z_.-]+$/)
.max(20)
.messages({
"string.pattern.base": "Invalid ApeKey name",
"string.max": "ApeKey name exceeds maximum of 20 characters",
});
const checkIfUserCanManageApeKeys = checkUserPermissions({
criteria: (user) => {
// Must be an exact check
return user.canManageApeKeys !== false;
},
invalidMessage: "You have lost access to ape keys, please contact support",
});
const router = Router();
router.use(
const commonMiddleware = [
validate({
criteria: (configuration) => {
return configuration.apeKeys.endpointsEnabled;
},
invalidMessage: "ApeKeys are currently disabled.",
})
);
router.get(
"/",
authenticateRequest(),
RateLimit.apeKeysGet,
checkIfUserCanManageApeKeys,
asyncHandler(ApeKeyController.getApeKeys)
);
router.post(
"/",
authenticateRequest(),
RateLimit.apeKeysGenerate,
checkIfUserCanManageApeKeys,
validateRequest({
body: {
name: apeKeyNameSchema.required(),
enabled: joi.boolean().required(),
},
}),
asyncHandler(ApeKeyController.generateApeKey)
);
router.patch(
"/:apeKeyId",
authenticateRequest(),
RateLimit.apeKeysUpdate,
checkIfUserCanManageApeKeys,
validateRequest({
params: {
apeKeyId: joi.string().token().required(),
},
body: {
name: apeKeyNameSchema,
enabled: joi.boolean(),
checkUserPermissions({
criteria: (user) => {
return user.canManageApeKeys ?? false;
},
invalidMessage: "You have lost access to ape keys, please contact support",
}),
asyncHandler(ApeKeyController.editApeKey)
);
];
router.delete(
"/:apeKeyId",
authenticateRequest(),
RateLimit.apeKeysDelete,
checkIfUserCanManageApeKeys,
validateRequest({
params: {
apeKeyId: joi.string().token().required(),
},
}),
asyncHandler(ApeKeyController.deleteApeKey)
);
export default router;
const s = initServer();
export default s.router(apeKeysContract, {
get: {
middleware: [...commonMiddleware, RateLimit.apeKeysGet],
handler: async (r) => callController(ApeKeyController.getApeKeys)(r),
},
add: {
middleware: [...commonMiddleware, RateLimit.apeKeysGenerate],
handler: async (r) => callController(ApeKeyController.generateApeKey)(r),
},
save: {
middleware: [...commonMiddleware, RateLimit.apeKeysUpdate],
handler: async (r) => callController(ApeKeyController.editApeKey)(r),
},
delete: {
middleware: [...commonMiddleware, RateLimit.apeKeysDelete],
handler: async (r) => callController(ApeKeyController.deleteApeKey)(r),
},
});

View file

@ -47,7 +47,6 @@ const API_ROUTE_MAP = {
"/public": publicStats,
"/leaderboards": leaderboards,
"/quotes": quotes,
"/ape-keys": apeKeys,
"/admin": admin,
"/webhooks": webhooks,
"/docs": docs,
@ -55,6 +54,7 @@ const API_ROUTE_MAP = {
const s = initServer();
const router = s.router(contract, {
apeKeys,
configs,
presets,
});

View file

@ -34,8 +34,7 @@ export async function getApeKey(
}
export async function countApeKeysForUser(uid: string): Promise<number> {
const apeKeys = await getApeKeys(uid);
return _.size(apeKeys);
return getApeKeysCollection().countDocuments({ uid });
}
export async function addApeKey(apeKey: MonkeyTypes.ApeKeyDB): Promise<string> {
@ -64,9 +63,11 @@ async function updateApeKey(
export async function editApeKey(
uid: string,
keyId: string,
name: string,
enabled: boolean
name?: string,
enabled?: boolean
): Promise<void> {
//check if there is a change
if (name === undefined && enabled === undefined) return;
const apeKeyUpdates = {
name,
enabled,

View file

@ -27,10 +27,6 @@
"name": "psas",
"description": "Public service announcements"
},
{
"name": "ape-keys",
"description": "ApeKey data and related operations"
},
{
"name": "leaderboards",
"description": "Leaderboard data"
@ -434,92 +430,6 @@
}
}
},
"/ape-keys": {
"get": {
"tags": ["ape-keys"],
"summary": "Gets ApeKeys created by a user",
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
},
"post": {
"tags": ["ape-keys"],
"summary": "Creates an ApeKey",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"enabled": {
"type": "boolean"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/ape-keys/{apeKeyId}": {
"patch": {
"tags": ["ape-keys"],
"summary": "Updates an ApeKey",
"parameters": [
{
"in": "path",
"name": "apeKeyId",
"required": true,
"type": "string"
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
},
"delete": {
"tags": ["ape-keys"],
"summary": "Deletes an ApeKey",
"parameters": [
{
"in": "path",
"name": "apeKeyId",
"required": true,
"type": "string"
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/leaderboards": {
"get": {
"tags": ["leaderboards"],

View file

@ -76,7 +76,7 @@ declare namespace MonkeyTypes {
_id: ObjectId;
};
type ApeKeyDB = import("@monkeytype/shared-types").ApeKey & {
type ApeKeyDB = import("@monkeytype/contracts/schemas/ape-keys").ApeKey & {
_id: ObjectId;
uid: string;
hash: string;

View file

@ -1,33 +0,0 @@
const BASE_PATH = "/ape-keys";
export default class ApeKeys {
constructor(private httpClient: Ape.HttpClient) {
this.httpClient = httpClient;
}
async get(): Ape.EndpointResponse<Ape.ApeKeys.GetApeKeys> {
return await this.httpClient.get(BASE_PATH);
}
async generate(
name: string,
enabled: boolean
): Ape.EndpointResponse<Ape.ApeKeys.GenerateApeKey> {
const payload = { name, enabled };
return await this.httpClient.post(BASE_PATH, { payload });
}
async update(
apeKeyId: string,
updates: { name?: string; enabled?: boolean }
): Ape.EndpointResponse<null> {
const payload = { ...updates };
const encoded = encodeURIComponent(apeKeyId);
return await this.httpClient.patch(`${BASE_PATH}/${encoded}`, { payload });
}
async delete(apeKeyId: string): Ape.EndpointResponse<null> {
const encoded = encodeURIComponent(apeKeyId);
return await this.httpClient.delete(`${BASE_PATH}/${encoded}`);
}
}

View file

@ -3,7 +3,6 @@ import Psas from "./psas";
import Quotes from "./quotes";
import Results from "./results";
import Users from "./users";
import ApeKeys from "./ape-keys";
import Public from "./public";
import Configuration from "./configuration";
import Dev from "./dev";
@ -15,7 +14,6 @@ export default {
Quotes,
Results,
Users,
ApeKeys,
Configuration,
Dev,
};

View file

@ -4,6 +4,7 @@ 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";
import { apeKeysContract } from "@monkeytype/contracts/ape-keys";
const API_PATH = "";
const BASE_URL = envConfig.backendUrl;
@ -21,7 +22,7 @@ const Ape = {
leaderboards: new endpoints.Leaderboards(httpClient),
presets: buildClient(presetsContract, BASE_URL, 10_000),
publicStats: new endpoints.Public(httpClient),
apeKeys: new endpoints.ApeKeys(httpClient),
apeKeys: buildClient(apeKeysContract, BASE_URL, 10_000),
configuration: new endpoints.Configuration(httpClient),
dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)),
};

View file

@ -1,11 +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.ApeKeys {
type GetApeKeys = Record<string, import("@monkeytype/shared-types").ApeKey>;
type GenerateApeKey = {
apeKey: string;
apeKeyId: string;
apeKeyDetails: import("@monkeytype/shared-types").ApeKey;
};
}

View file

@ -5,9 +5,9 @@ import { format } from "date-fns/format";
import * as ConnectionState from "../states/connection";
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
import { showPopup } from "./simple-modals";
import { ApeKey } from "@monkeytype/shared-types";
import { ApeKey, ApeKeys } from "@monkeytype/contracts/schemas/ape-keys";
let apeKeys: Ape.ApeKeys.GetApeKeys | null = {};
let apeKeys: ApeKeys | null = {};
async function getData(): Promise<void> {
Loader.show();
@ -15,11 +15,11 @@ async function getData(): Promise<void> {
Loader.hide();
if (response.status !== 200) {
Notifications.add("Error getting ape keys: " + response.message, -1);
Notifications.add("Error getting ape keys: " + response.body.message, -1);
return undefined;
}
apeKeys = response.data;
apeKeys = response.body.data;
}
function refreshList(): void {
@ -113,10 +113,13 @@ async function toggleActiveKey(keyId: string): Promise<void> {
const key = apeKeys?.[keyId];
if (!key || apeKeys === undefined) return;
Loader.show();
const response = await Ape.apeKeys.update(keyId, { enabled: !key.enabled });
const response = await Ape.apeKeys.save({
params: { apeKeyId: keyId },
body: { enabled: !key.enabled },
});
Loader.hide();
if (response.status !== 200) {
Notifications.add("Failed to update key: " + response.message, -1);
Notifications.add("Failed to update key: " + response.body.message, -1);
return;
}
key.enabled = !key.enabled;

View file

@ -163,6 +163,7 @@ type SimpleModalOptions = {
title: string;
inputs?: CommonInputType[];
text?: string;
textAllowHtml?: boolean;
buttonText: string;
execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise<ExecReturn>;
beforeInitFn?: (thisPopup: SimpleModal) => void;
@ -197,6 +198,7 @@ class SimpleModal {
title: string;
inputs: CommonInputType[];
text?: string;
textAllowHtml: boolean;
buttonText: string;
execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise<ExecReturn>;
beforeInitFn: ((thisPopup: SimpleModal) => void) | undefined;
@ -212,6 +214,7 @@ class SimpleModal {
this.title = options.title;
this.inputs = options.inputs ?? [];
this.text = options.text;
this.textAllowHtml = options.textAllowHtml ?? false;
this.wrapper = modal.getWrapper();
this.element = modal.getModal();
this.buttonText = options.buttonText;
@ -236,7 +239,11 @@ class SimpleModal {
this.reset();
el.attr("data-popup-id", this.id);
el.find(".title").text(this.title);
el.find(".text").text(this.text ?? "");
if (this.textAllowHtml) {
el.find(".text").html(this.text ?? "");
} else {
el.find(".text").text(this.text ?? "");
}
this.initInputs();
@ -1468,16 +1475,15 @@ list.generateApeKey = new SimpleModal({
buttonText: "generate",
onlineOnly: true,
execFn: async (_thisPopup, name): Promise<ExecReturn> => {
const response = await Ape.apeKeys.generate(name, false);
const response = await Ape.apeKeys.add({ body: { name, enabled: false } });
if (response.status !== 200) {
return {
status: -1,
message: "Failed to generate key: " + response.message,
message: "Failed to generate key: " + response.body.message,
};
}
//if response is 200 data is guaranteed to not be null
const data = response.data as Ape.ApeKeys.GenerateApeKey;
const data = response.body.data;
const modalChain = modal.getPreviousModalInChain();
return {
@ -1508,7 +1514,10 @@ list.viewApeKey = new SimpleModal({
initVal: "",
},
],
text: "This is your new Ape Key. Please keep it safe. You will only see it once!",
textAllowHtml: true,
text: `
This is your new Ape Key. Please keep it safe. You will only see it once!<br><br>
<strong>Note:</strong> Ape Keys are disabled by default, you need to enable them before they can be used.`,
buttonText: "close",
hideCallsExec: true,
execFn: async (_thisPopup): Promise<ExecReturn> => {
@ -1543,11 +1552,13 @@ list.deleteApeKey = new SimpleModal({
buttonText: "delete",
onlineOnly: true,
execFn: async (_thisPopup): Promise<ExecReturn> => {
const response = await Ape.apeKeys.delete(_thisPopup.parameters[0] ?? "");
const response = await Ape.apeKeys.delete({
params: { apeKeyId: _thisPopup.parameters[0] ?? "" },
});
if (response.status !== 200) {
return {
status: -1,
message: "Failed to delete key: " + response.message,
message: "Failed to delete key: " + response.body.message,
};
}
@ -1574,13 +1585,16 @@ list.editApeKey = new SimpleModal({
buttonText: "edit",
onlineOnly: true,
execFn: async (_thisPopup, input): Promise<ExecReturn> => {
const response = await Ape.apeKeys.update(_thisPopup.parameters[0] ?? "", {
name: input,
const response = await Ape.apeKeys.save({
params: { apeKeyId: _thisPopup.parameters[0] ?? "" },
body: {
name: input,
},
});
if (response.status !== 200) {
return {
status: -1,
message: "Failed to update key: " + response.message,
message: "Failed to update key: " + response.body.message,
};
}
return {

View file

@ -0,0 +1,96 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
EndpointMetadata,
MonkeyResponseSchema,
responseWithData,
} from "./schemas/api";
import { IdSchema } from "./schemas/util";
import {
ApeKeySchema,
ApeKeysSchema,
ApeKeyUserDefinedSchema,
} from "./schemas/ape-keys";
export const GetApeKeyResponseSchema = responseWithData(ApeKeysSchema);
export type GetApeKeyResponse = z.infer<typeof GetApeKeyResponseSchema>;
export const AddApeKeyRequestSchema = ApeKeyUserDefinedSchema;
export type AddApeKeyRequest = z.infer<typeof AddApeKeyRequestSchema>;
export const AddApeKeyResponseSchema = responseWithData(
z.object({
apeKeyId: IdSchema,
apeKey: z.string().base64(),
apeKeyDetails: ApeKeySchema,
})
);
export type AddApeKeyResponse = z.infer<typeof AddApeKeyResponseSchema>;
export const EditApeKeyRequestSchema = AddApeKeyRequestSchema.partial();
export type EditApeKeyRequest = z.infer<typeof EditApeKeyRequestSchema>;
export const ApeKeyParamsSchema = z.object({
apeKeyId: IdSchema,
});
export type ApeKeyParams = z.infer<typeof ApeKeyParamsSchema>;
const c = initContract();
export const apeKeysContract = c.router(
{
get: {
summary: "get ape keys",
description: "Get ape keys of the current user.",
method: "GET",
path: "/",
responses: {
200: GetApeKeyResponseSchema,
},
},
add: {
summary: "add ape key",
description: "Add an ape key for the current user.",
method: "POST",
path: "/",
body: AddApeKeyRequestSchema.strict(),
responses: {
200: AddApeKeyResponseSchema,
},
},
save: {
summary: "update ape key",
description: "Update an existing ape key for the current user.",
method: "PATCH",
path: "/:apeKeyId",
pathParams: ApeKeyParamsSchema,
body: EditApeKeyRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
},
delete: {
summary: "delete ape key",
description: "Delete ape key by id.",
method: "DELETE",
path: "/:apeKeyId",
pathParams: ApeKeyParamsSchema,
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
},
},
{
pathPrefix: "/ape-keys",
strictStatusCodes: true,
metadata: {
openApiTags: "ape-keys",
} as EndpointMetadata,
commonResponses: CommonResponses,
}
);

View file

@ -1,10 +1,12 @@
import { initContract } from "@ts-rest/core";
import { configsContract } from "./configs";
import { presetsContract } from "./presets";
import { apeKeysContract } from "./ape-keys";
const c = initContract();
export const contract = c.router({
apeKeys: apeKeysContract,
configs: configsContract,
presets: presetsContract,
});

View file

@ -0,0 +1,20 @@
import { z } from "zod";
import { IdSchema } from "./util";
export const ApeKeyUserDefinedSchema = z.object({
name: z
.string()
.regex(/^[0-9a-zA-Z_.-]+$/)
.max(20),
enabled: z.boolean(),
});
export const ApeKeySchema = ApeKeyUserDefinedSchema.extend({
createdOn: z.number().min(0),
modifiedOn: z.number().min(0),
lastUsedOn: z.number().min(0).or(z.literal(-1)),
});
export type ApeKey = z.infer<typeof ApeKeySchema>;
export const ApeKeysSchema = z.record(IdSchema, ApeKeySchema);
export type ApeKeys = z.infer<typeof ApeKeysSchema>;

View file

@ -1,6 +1,6 @@
import { z, ZodSchema } from "zod";
export type OpenApiTag = "configs" | "presets";
export type OpenApiTag = "configs" | "presets" | "ape-keys";
export type EndpointMetadata = {
/** Authentication options, by default a bearer token is required. */

View file

@ -320,14 +320,6 @@ export type PublicTypingStats = {
testsStarted: number;
};
export type ApeKey = {
name: string;
enabled: boolean;
createdOn: number;
modifiedOn: number;
lastUsedOn: number;
};
export type LeaderboardEntry = {
_id: string;
wpm: number;