diff --git a/backend/__tests__/api/controllers/dev.spec.ts b/backend/__tests__/api/controllers/dev.spec.ts new file mode 100644 index 000000000000..0ac16cab2065 --- /dev/null +++ b/backend/__tests__/api/controllers/dev.spec.ts @@ -0,0 +1,59 @@ +import request from "supertest"; +import app from "../../../src/app"; + +import { ObjectId } from "mongodb"; +import * as Misc from "../../../src/utils/misc"; + +const uid = new ObjectId().toHexString(); +const mockApp = request(app); + +describe("DevController", () => { + describe("generate testData", () => { + const isDevEnvironmentMock = vi.spyOn(Misc, "isDevEnvironment"); + + beforeEach(() => { + isDevEnvironmentMock.mockReset(); + isDevEnvironmentMock.mockReturnValue(true); + }); + + it("should fail on prod", async () => { + //GIVEN + isDevEnvironmentMock.mockReturnValue(false); + //WHEN + const { body } = await mockApp + .post("/dev/generateData") + .send({ username: "test" }) + .expect(503); + //THEN + expect(body.message).toEqual( + "Development endpoints are only available in DEV mode." + ); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/dev/generateData") + .send({}) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [`"username" Required`], + }); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .post("/dev/generateData") + .send({ username: "Bob", extra: "value" }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + }); +}); diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index d3a9354d17dd..02b814e2f157 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -105,6 +105,13 @@ export function getOpenApi(): OpenAPIObject { "x-displayName": "Server configuration", "x-public": "yes", }, + { + name: "dev", + description: + "Development related endpoints. Only available on dev environment", + "x-displayName": "Development", + "x-public": "no", + }, ], }, diff --git a/backend/src/api/controllers/dev.ts b/backend/src/api/controllers/dev.ts index 37175651eed6..e380cfc9d671 100644 --- a/backend/src/api/controllers/dev.ts +++ b/backend/src/api/controllers/dev.ts @@ -1,4 +1,4 @@ -import { MonkeyResponse } from "../../utils/monkey-response"; +import { MonkeyResponse2 } from "../../utils/monkey-response"; import * as UserDal from "../../dal/user"; import FirebaseAdmin from "../../init/firebase-admin"; import Logger from "../../utils/logger"; @@ -9,30 +9,27 @@ import { roundTo2 } from "../../utils/misc"; import { ObjectId } from "mongodb"; import * as LeaderboardDal from "../../dal/leaderboards"; import MonkeyError from "../../utils/error"; -import isNumber from "lodash/isNumber"; + import { Mode, PersonalBest, PersonalBests, } from "@monkeytype/contracts/schemas/shared"; +import { + GenerateDataRequest, + GenerateDataResponse, +} from "@monkeytype/contracts/dev"; -type GenerateDataOptions = { - firstTestTimestamp: Date; - lastTestTimestamp: Date; - minTestsPerDay: number; - maxTestsPerDay: number; -}; - -const CREATE_RESULT_DEFAULT_OPTIONS: GenerateDataOptions = { - firstTestTimestamp: DateUtils.startOfDay(new UTCDate(Date.now())), - lastTestTimestamp: DateUtils.endOfDay(new UTCDate(Date.now())), +const CREATE_RESULT_DEFAULT_OPTIONS = { + firstTestTimestamp: DateUtils.startOfDay(new UTCDate(Date.now())).valueOf(), + lastTestTimestamp: DateUtils.endOfDay(new UTCDate(Date.now())).valueOf(), minTestsPerDay: 0, maxTestsPerDay: 50, }; export async function createTestData( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { username, createUser } = req.body; const user = await getOrCreateUser(username, "password", createUser); @@ -42,7 +39,7 @@ export async function createTestData( await updateUser(uid); await updateLeaderboard(); - return new MonkeyResponse("test data created", { uid, email }, 200); + return new MonkeyResponse2("test data created", { uid, email }); } async function getOrCreateUser( @@ -73,20 +70,18 @@ async function getOrCreateUser( async function createTestResults( user: MonkeyTypes.DBUser, - configOptions: Partial + configOptions: GenerateDataRequest ): Promise { const config = { ...CREATE_RESULT_DEFAULT_OPTIONS, ...configOptions, }; - if (isNumber(config.firstTestTimestamp)) - config.firstTestTimestamp = toDate(config.firstTestTimestamp); - if (isNumber(config.lastTestTimestamp)) - config.lastTestTimestamp = toDate(config.lastTestTimestamp); + const start = toDate(config.firstTestTimestamp); + const end = toDate(config.lastTestTimestamp); const days = DateUtils.eachDayOfInterval({ - start: config.firstTestTimestamp, - end: config.lastTestTimestamp, + start, + end, }).map((day) => ({ timestamp: DateUtils.startOfDay(day), amount: Math.round(random(config.minTestsPerDay, config.maxTestsPerDay)), diff --git a/backend/src/api/routes/dev.ts b/backend/src/api/routes/dev.ts index 2c2dd2a42cb4..3b4678a20bc6 100644 --- a/backend/src/api/routes/dev.ts +++ b/backend/src/api/routes/dev.ts @@ -1,35 +1,15 @@ -import { Router } from "express"; -import joi from "joi"; -import { createTestData } from "../controllers/dev"; -import { isDevEnvironment } from "../../utils/misc"; -import { validate } from "../../middlewares/configuration"; -import { validateRequest } from "../../middlewares/validation"; -import { asyncHandler } from "../../middlewares/utility"; +import { devContract } from "@monkeytype/contracts/dev"; +import { initServer } from "@ts-rest/express"; -const router = Router(); +import * as DevController from "../controllers/dev"; +import { callController } from "../ts-rest-adapter"; +import { onlyAvailableOnDev } from "../../middlewares/utility"; -router.use( - validate({ - criteria: () => { - return isDevEnvironment(); - }, - invalidMessage: "Development endpoints are only available in DEV mode.", - }) -); +const s = initServer(); -router.post( - "/generateData", - validateRequest({ - body: { - username: joi.string().required(), - createUser: joi.boolean().optional(), - firstTestTimestamp: joi.number().optional(), - lastTestTimestamp: joi.number().optional(), - minTestsPerDay: joi.number().optional(), - maxTestsPerDay: joi.number().optional(), - }, - }), - asyncHandler(createTestData) -); - -export default router; +export default s.router(devContract, { + generateData: { + middleware: [onlyAvailableOnDev()], + handler: async (r) => callController(DevController.createTestData)(r), + }, +}); diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index df4f7988ab87..725360033ff8 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -57,6 +57,7 @@ const router = s.router(contract, { leaderboards, results, configuration, + dev, }); export function addApiRoutes(app: Application): void { @@ -139,9 +140,6 @@ function applyDevApiRoutes(app: Application): void { } next(); }); - - //enable dev edpoints - app.use("/dev", dev); } } diff --git a/backend/src/middlewares/utility.ts b/backend/src/middlewares/utility.ts index d202ffc6aeaa..383675c15768 100644 --- a/backend/src/middlewares/utility.ts +++ b/backend/src/middlewares/utility.ts @@ -2,6 +2,8 @@ import _ from "lodash"; import type { Request, Response, NextFunction, RequestHandler } from "express"; import { handleMonkeyResponse, MonkeyResponse } from "../utils/monkey-response"; import { recordClientVersion as prometheusRecordClientVersion } from "../utils/prometheus"; +import { validate } from "./configuration"; +import { isDevEnvironment } from "../utils/misc"; export const emptyMiddleware = ( _req: MonkeyTypes.Request, @@ -49,3 +51,12 @@ export function recordClientVersion(): RequestHandler { next(); }; } + +export function onlyAvailableOnDev(): RequestHandler { + return validate({ + criteria: () => { + return isDevEnvironment(); + }, + invalidMessage: "Development endpoints are only available in DEV mode.", + }); +} diff --git a/frontend/src/ts/ape/endpoints/dev.ts b/frontend/src/ts/ape/endpoints/dev.ts deleted file mode 100644 index 0128ed376e7f..000000000000 --- a/frontend/src/ts/ape/endpoints/dev.ts +++ /dev/null @@ -1,15 +0,0 @@ -const BASE_PATH = "/dev"; - -export default class Dev { - constructor(private httpClient: Ape.HttpClient) { - this.httpClient = httpClient; - } - - async generateData( - params: Ape.Dev.GenerateData - ): Ape.EndpointResponse { - return await this.httpClient.post(BASE_PATH + "/generateData", { - payload: params, - }); - } -} diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts index 240fbcbf1bc0..8cf7685b8aee 100644 --- a/frontend/src/ts/ape/endpoints/index.ts +++ b/frontend/src/ts/ape/endpoints/index.ts @@ -1,9 +1,7 @@ import Quotes from "./quotes"; import Users from "./users"; -import Dev from "./dev"; export default { Quotes, Users, - Dev, }; diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts index 79013111f124..67ea6615b5cd 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 { contract } from "@monkeytype/contracts"; +import { devContract } from "@monkeytype/contracts/dev"; const API_PATH = ""; const BASE_URL = envConfig.backendUrl; @@ -10,13 +11,14 @@ const API_URL = `${BASE_URL}${API_PATH}`; const httpClient = buildHttpClient(API_URL, 10_000); const tsRestClient = buildClient(contract, BASE_URL, 10_000); +const devClient = buildClient(devContract, BASE_URL, 240_000); // API Endpoints const Ape = { ...tsRestClient, users: new endpoints.Users(httpClient), quotes: new endpoints.Quotes(httpClient), - dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)), + dev: devClient, }; export default Ape; diff --git a/frontend/src/ts/ape/types/dev.d.ts b/frontend/src/ts/ape/types/dev.d.ts deleted file mode 100644 index 9d3755a3b72b..000000000000 --- a/frontend/src/ts/ape/types/dev.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -declare namespace Ape.Dev { - type GenerateData = { - username: string; - createUser?: boolean; - firstTestTimestamp?: number; - lastTestTimestamp?: number; - minTestsPerDay?: number; - maxTestsPerDay?: number; - }; - type GenerateDataResponse = { - uid: string; - email: string; - }; -} diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index ab13d34c0d14..f40330366939 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -34,6 +34,7 @@ import { TextInput, } from "../utils/simple-modal"; import { ShowOptions } from "../utils/animated-modal"; +import { GenerateDataRequest } from "@monkeytype/contracts/dev"; type PopupKey = | "updateEmail" @@ -1308,7 +1309,7 @@ list.devGenerateData = new SimpleModal({ minTestsPerDay, maxTestsPerDay ): Promise => { - const request: Ape.Dev.GenerateData = { + const request: GenerateDataRequest = { username, createUser: createUser === "true", }; @@ -1321,11 +1322,11 @@ list.devGenerateData = new SimpleModal({ if (maxTestsPerDay !== undefined && maxTestsPerDay.length > 0) request.maxTestsPerDay = Number.parseInt(maxTestsPerDay); - const result = await Ape.dev.generateData(request); + const result = await Ape.dev.generateData({ body: request }); return { status: result.status === 200 ? 1 : -1, - message: result.message, + message: result.body.message, hideOptions: { clearModalChain: true, }, diff --git a/packages/contracts/src/dev.ts b/packages/contracts/src/dev.ts new file mode 100644 index 000000000000..1a5a19623e61 --- /dev/null +++ b/packages/contracts/src/dev.ts @@ -0,0 +1,58 @@ +import { initContract } from "@ts-rest/core"; +import { z } from "zod"; +import { + CommonResponses, + EndpointMetadata, + responseWithData, +} from "./schemas/api"; +import { IdSchema } from "./schemas/util"; + +export const GenerateDataRequestSchema = z.object({ + username: z.string(), + createUser: z + .boolean() + .optional() + .describe( + "If `true` create user with @example.com and password `password`. If false user has to exist." + ), + firstTestTimestamp: z.number().int().nonnegative().optional(), + lastTestTimestamp: z.number().int().nonnegative().optional(), + minTestsPerDay: z.number().int().nonnegative().optional(), + maxTestsPerDay: z.number().int().nonnegative().optional(), +}); +export type GenerateDataRequest = z.infer; + +export const GenerateDataResponseSchema = responseWithData( + z.object({ + uid: IdSchema, + email: z.string().email(), + }) +); +export type GenerateDataResponse = z.infer; + +const c = initContract(); +export const devContract = c.router( + { + generateData: { + summary: "generate data", + description: "Generate test results for the given user.", + method: "POST", + path: "/generateData", + body: GenerateDataRequestSchema.strict(), + responses: { + 200: GenerateDataResponseSchema, + }, + }, + }, + { + pathPrefix: "/dev", + strictStatusCodes: true, + metadata: { + openApiTags: "dev", + authenticationOptions: { + isPublic: true, + }, + } as EndpointMetadata, + commonResponses: CommonResponses, + } +); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 321af82ac64b..dd7068fd1a03 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -8,6 +8,7 @@ import { publicContract } from "./public"; import { leaderboardsContract } from "./leaderboards"; import { resultsContract } from "./results"; import { configurationContract } from "./configuration"; +import { devContract } from "./dev"; const c = initContract(); @@ -21,4 +22,5 @@ export const contract = c.router({ leaderboards: leaderboardsContract, results: resultsContract, configuration: configurationContract, + dev: devContract, }); diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index ff7a39240502..aec157b19209 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -9,7 +9,8 @@ export type OpenApiTag = | "public" | "leaderboards" | "results" - | "configuration"; + | "configuration" + | "dev"; export type EndpointMetadata = { /** Authentication options, by default a bearer token is required. */