aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--backend/__tests__/api/controllers/public.spec.ts144
-rw-r--r--backend/scripts/openapi.ts5
-rw-r--r--backend/src/api/controllers/public.ts29
-rw-r--r--backend/src/api/routes/index.ts29
-rw-r--r--backend/src/api/routes/public.ts54
-rw-r--r--backend/src/dal/public.ts9
-rw-r--r--backend/src/utils/pb.ts5
-rw-r--r--backend/src/utils/prometheus.ts2
-rw-r--r--frontend/src/ts/ape/endpoints/index.ts2
-rw-r--r--frontend/src/ts/ape/endpoints/public.ts27
-rw-r--r--frontend/src/ts/ape/index.ts3
-rw-r--r--frontend/src/ts/modals/pb-tables.ts2
-rw-r--r--frontend/src/ts/modals/share-test-settings.ts2
-rw-r--r--frontend/src/ts/pages/about.ts27
-rw-r--r--frontend/src/ts/test/pace-caret.ts5
-rw-r--r--packages/contracts/src/index.ts2
-rw-r--r--packages/contracts/src/public.ts70
-rw-r--r--packages/contracts/src/schemas/api.ts8
-rw-r--r--packages/contracts/src/schemas/public.ts15
-rw-r--r--packages/contracts/src/schemas/shared.ts14
-rw-r--r--packages/contracts/src/schemas/util.ts16
-rw-r--r--packages/shared-types/src/index.ts11
22 files changed, 349 insertions, 132 deletions
diff --git a/backend/__tests__/api/controllers/public.spec.ts b/backend/__tests__/api/controllers/public.spec.ts
new file mode 100644
index 000000000..accc90a9a
--- /dev/null
+++ b/backend/__tests__/api/controllers/public.spec.ts
@@ -0,0 +1,144 @@
+import request from "supertest";
+import app from "../../../src/app";
+import * as PublicDal from "../../../src/dal/public";
+const mockApp = request(app);
+
+describe("PublicController", () => {
+ describe("get speed histogram", () => {
+ const getSpeedHistogramMock = vi.spyOn(PublicDal, "getSpeedHistogram");
+
+ afterEach(() => {
+ getSpeedHistogramMock.mockReset();
+ });
+
+ it("gets for english time 60", async () => {
+ //GIVEN
+ getSpeedHistogramMock.mockResolvedValue({ "0": 1, "10": 2 });
+
+ //WHEN
+ const { body } = await mockApp
+ .get("/public/speedHistogram")
+ .query({ language: "english", mode: "time", mode2: "60" });
+ //.expect(200);
+ console.log(body);
+
+ //THEN
+ expect(body).toEqual({
+ message: "Public speed histogram retrieved",
+ data: { "0": 1, "10": 2 },
+ });
+
+ expect(getSpeedHistogramMock).toHaveBeenCalledWith(
+ "english",
+ "time",
+ "60"
+ );
+ });
+
+ it("gets for mode", async () => {
+ for (const mode of ["time", "words", "quote", "zen", "custom"]) {
+ const response = await mockApp
+ .get("/public/speedHistogram")
+ .query({ language: "english", mode, mode2: "custom" });
+ expect(response.status, "for mode " + mode).toEqual(200);
+ }
+ });
+
+ it("gets for mode2", async () => {
+ for (const mode2 of [
+ "10",
+ "25",
+ "50",
+ "100",
+ "15",
+ "30",
+ "60",
+ "120",
+ "zen",
+ "custom",
+ ]) {
+ const response = await mockApp
+ .get("/public/speedHistogram")
+ .query({ language: "english", mode: "words", mode2 });
+
+ expect(response.status, "for mode2 " + mode2).toEqual(200);
+ }
+ });
+ it("fails for missing query", async () => {
+ const { body } = await mockApp.get("/public/speedHistogram").expect(422);
+
+ expect(body).toEqual({
+ message: "Invalid query schema",
+ validationErrors: [
+ '"language" Required',
+ '"mode" Required',
+ '"mode2" Needs to be either a number, "zen" or "custom."',
+ ],
+ });
+ });
+ it("fails for invalid query", async () => {
+ const { body } = await mockApp
+ .get("/public/speedHistogram")
+ .query({
+ language: "en?gli.sh",
+ mode: "unknownMode",
+ mode2: "unknownMode2",
+ })
+ .expect(422);
+
+ expect(body).toEqual({
+ message: "Invalid query schema",
+ validationErrors: [
+ '"language" Invalid',
+ `"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'unknownMode'`,
+ '"mode2" Needs to be either a number, "zen" or "custom."',
+ ],
+ });
+ });
+ it("fails for unknown query", async () => {
+ const { body } = await mockApp
+ .get("/public/speedHistogram")
+ .query({
+ language: "english",
+ mode: "time",
+ mode2: "60",
+ extra: "value",
+ })
+ .expect(422);
+
+ expect(body).toEqual({
+ message: "Invalid query schema",
+ validationErrors: ["Unrecognized key(s) in object: 'extra'"],
+ });
+ });
+ });
+ describe("get typing stats", () => {
+ const getTypingStatsMock = vi.spyOn(PublicDal, "getTypingStats");
+
+ afterEach(() => {
+ getTypingStatsMock.mockReset();
+ });
+
+ it("gets without authentication", async () => {
+ //GIVEN
+ getTypingStatsMock.mockResolvedValue({
+ testsCompleted: 23,
+ testsStarted: 42,
+ timeTyping: 1000,
+ } as any);
+
+ //WHEN
+ const { body } = await mockApp.get("/public/typingStats").expect(200);
+
+ //THEN
+ expect(body).toEqual({
+ message: "Public typing stats retrieved",
+ data: {
+ testsCompleted: 23,
+ testsStarted: 42,
+ timeTyping: 1000,
+ },
+ });
+ });
+ });
+});
diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts
index 4b444caa1..b5140de6c 100644
--- a/backend/scripts/openapi.ts
+++ b/backend/scripts/openapi.ts
@@ -67,6 +67,11 @@ export function getOpenApi(): OpenAPIObject {
"x-displayName": "Ape Keys",
},
{
+ name: "public",
+ description: "Public endpoints such as typing stats.",
+ "x-displayName": "public",
+ },
+ {
name: "psas",
description: "Public service announcements.",
"x-displayName": "PSAs",
diff --git a/backend/src/api/controllers/public.ts b/backend/src/api/controllers/public.ts
index f49dca031..505f5caa9 100644
--- a/backend/src/api/controllers/public.ts
+++ b/backend/src/api/controllers/public.ts
@@ -1,21 +1,22 @@
+import {
+ GetSpeedHistogramQuery,
+ GetSpeedHistogramResponse,
+ GetTypingStatsResponse,
+} from "@monkeytype/contracts/public";
import * as PublicDAL from "../../dal/public";
-import { MonkeyResponse } from "../../utils/monkey-response";
+import { MonkeyResponse2 } from "../../utils/monkey-response";
-export async function getPublicSpeedHistogram(
- req: MonkeyTypes.Request
-): Promise<MonkeyResponse> {
+export async function getSpeedHistogram(
+ req: MonkeyTypes.Request2<GetSpeedHistogramQuery>
+): Promise<GetSpeedHistogramResponse> {
const { language, mode, mode2 } = req.query;
- const data = await PublicDAL.getSpeedHistogram(
- language as string,
- mode as string,
- mode2 as string
- );
- return new MonkeyResponse("Public speed histogram retrieved", data);
+ const data = await PublicDAL.getSpeedHistogram(language, mode, mode2);
+ return new MonkeyResponse2("Public speed histogram retrieved", data);
}
-export async function getPublicTypingStats(
- _req: MonkeyTypes.Request
-): Promise<MonkeyResponse> {
+export async function getTypingStats(
+ _req: MonkeyTypes.Request2
+): Promise<GetTypingStatsResponse> {
const data = await PublicDAL.getTypingStats();
- return new MonkeyResponse("Public typing stats retrieved", data);
+ return new MonkeyResponse2("Public typing stats retrieved", data);
}
diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts
index c64ee8cfd..453a4c128 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,
- "/public": publicStats,
"/leaderboards": leaderboards,
"/quotes": quotes,
"/webhooks": webhooks,
@@ -57,6 +56,7 @@ const router = s.router(contract, {
configs,
presets,
psas,
+ public: publicStats,
});
export function addApiRoutes(app: Application): void {
@@ -78,16 +78,29 @@ export function addApiRoutes(app: Application): void {
function applyTsRestApiRoutes(app: IRouter): void {
createExpressEndpoints(contract, router, app, {
jsonQuery: true,
- requestValidationErrorHandler(err, req, res, next) {
- if (err.body?.issues === undefined) {
+ requestValidationErrorHandler(err, _req, res, next) {
+ let message: string | undefined = undefined;
+ let validationErrors: string[] | undefined = undefined;
+
+ if (err.pathParams?.issues !== undefined) {
+ message = "Invalid path parameter schema";
+ validationErrors = err.pathParams.issues.map(prettyErrorMessage);
+ } else if (err.query?.issues !== undefined) {
+ message = "Invalid query schema";
+ validationErrors = err.query.issues.map(prettyErrorMessage);
+ } else if (err.body?.issues !== undefined) {
+ message = "Invalid request data schema";
+ validationErrors = err.body.issues.map(prettyErrorMessage);
+ }
+
+ if (message !== undefined) {
+ res
+ .status(422)
+ .json({ message, validationErrors } as MonkeyValidationError);
+ } else {
next();
return;
}
- const issues = err.body?.issues.map(prettyErrorMessage);
- res.status(422).json({
- message: "Invalid request data schema",
- validationErrors: issues,
- } as MonkeyValidationError);
},
globalMiddleware: [authenticateTsRestRequest()],
});
diff --git a/backend/src/api/routes/public.ts b/backend/src/api/routes/public.ts
index 86491c6b3..b5e310fb7 100644
--- a/backend/src/api/routes/public.ts
+++ b/backend/src/api/routes/public.ts
@@ -1,41 +1,17 @@
-import { Router } from "express";
-import * as PublicController from "../controllers/public";
+import { publicContract } from "@monkeytype/contracts/public";
+import { initServer } from "@ts-rest/express";
import * as RateLimit from "../../middlewares/rate-limit";
-import { asyncHandler } from "../../middlewares/utility";
-import joi from "joi";
-import { validateRequest } from "../../middlewares/validation";
-
-const GET_MODE_STATS_VALIDATION_SCHEMA = {
- language: joi
- .string()
- .max(50)
- .pattern(/^[a-zA-Z0-9_+]+$/)
- .required(),
- mode: joi
- .string()
- .valid("time", "words", "quote", "zen", "custom")
- .required(),
- mode2: joi
- .string()
- .regex(/^(\d)+|custom|zen/)
- .required(),
-};
-
-const router = Router();
-
-router.get(
- "/speedHistogram",
- RateLimit.publicStatsGet,
- validateRequest({
- query: GET_MODE_STATS_VALIDATION_SCHEMA,
- }),
- asyncHandler(PublicController.getPublicSpeedHistogram)
-);
-
-router.get(
- "/typingStats",
- RateLimit.publicStatsGet,
- asyncHandler(PublicController.getPublicTypingStats)
-);
+import * as PublicController from "../controllers/public";
+import { callController } from "../ts-rest-adapter";
-export default router;
+const s = initServer();
+export default s.router(publicContract, {
+ getSpeedHistogram: {
+ middleware: [RateLimit.publicStatsGet],
+ handler: async (r) => callController(PublicController.getSpeedHistogram)(r),
+ },
+ getTypingStats: {
+ middleware: [RateLimit.publicStatsGet],
+ handler: async (r) => callController(PublicController.getTypingStats)(r),
+ },
+});
diff --git a/backend/src/dal/public.ts b/backend/src/dal/public.ts
index 08b72edd0..50a230a78 100644
--- a/backend/src/dal/public.ts
+++ b/backend/src/dal/public.ts
@@ -1,10 +1,13 @@
import * as db from "../init/db";
import { roundTo2 } from "../utils/misc";
import MonkeyError from "../utils/error";
-import { PublicTypingStats, SpeedHistogram } from "@monkeytype/shared-types";
+import {
+ TypingStats,
+ SpeedHistogram,
+} from "@monkeytype/contracts/schemas/public";
-type PublicTypingStatsDB = PublicTypingStats & { _id: "stats" };
-type PublicSpeedStatsDB = {
+export type PublicTypingStatsDB = TypingStats & { _id: "stats" };
+export type PublicSpeedStatsDB = {
_id: "speedStatsHistogram";
english_time_15: SpeedHistogram;
english_time_60: SpeedHistogram;
diff --git a/backend/src/utils/pb.ts b/backend/src/utils/pb.ts
index 76041605d..b00d747a5 100644
--- a/backend/src/utils/pb.ts
+++ b/backend/src/utils/pb.ts
@@ -3,7 +3,6 @@ import FunboxList from "../constants/funbox-list";
import { DBResult } from "@monkeytype/shared-types";
import {
Mode,
- Mode2,
PersonalBest,
PersonalBests,
} from "@monkeytype/contracts/schemas/shared";
@@ -39,7 +38,7 @@ export function checkAndUpdatePb(
result: Result
): CheckAndUpdatePbResult {
const mode = result.mode;
- const mode2 = result.mode2 as Mode2<"time">;
+ const mode2 = result.mode2;
const userPb = userPersonalBests ?? {};
userPb[mode] ??= {};
@@ -175,7 +174,7 @@ function updateLeaderboardPersonalBests(
}
const mode = result.mode;
- const mode2 = result.mode2 as Mode2<"time">;
+ const mode2 = result.mode2;
lbPersonalBests[mode] = lbPersonalBests[mode] ?? {};
const lbMode2 = lbPersonalBests[mode][mode2] as MonkeyTypes.LbPersonalBests;
diff --git a/backend/src/utils/prometheus.ts b/backend/src/utils/prometheus.ts
index f98fdc90c..040e1492e 100644
--- a/backend/src/utils/prometheus.ts
+++ b/backend/src/utils/prometheus.ts
@@ -105,7 +105,7 @@ export function incrementResult(res: Result<Mode>): void {
punctuation,
} = res;
- let m2 = mode2 as string;
+ let m2 = mode2;
if (mode === "time" && !["15", "30", "60", "120"].includes(mode2)) {
m2 = "custom";
}
diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts
index 2dae7057b..609c791b9 100644
--- a/frontend/src/ts/ape/endpoints/index.ts
+++ b/frontend/src/ts/ape/endpoints/index.ts
@@ -2,13 +2,11 @@ import Leaderboards from "./leaderboards";
import Quotes from "./quotes";
import Results from "./results";
import Users from "./users";
-import Public from "./public";
import Configuration from "./configuration";
import Dev from "./dev";
export default {
Leaderboards,
- Public,
Quotes,
Results,
Users,
diff --git a/frontend/src/ts/ape/endpoints/public.ts b/frontend/src/ts/ape/endpoints/public.ts
deleted file mode 100644
index 5f4b417d3..000000000
--- a/frontend/src/ts/ape/endpoints/public.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { PublicTypingStats, SpeedHistogram } from "@monkeytype/shared-types";
-
-const BASE_PATH = "/public";
-
-type SpeedStatsQuery = {
- language: string;
- mode: string;
- mode2: string;
-};
-
-export default class Public {
- constructor(private httpClient: Ape.HttpClient) {
- this.httpClient = httpClient;
- }
-
- async getSpeedHistogram(
- searchQuery: SpeedStatsQuery
- ): Ape.EndpointResponse<SpeedHistogram> {
- return await this.httpClient.get(`${BASE_PATH}/speedHistogram`, {
- searchQuery,
- });
- }
-
- async getTypingStats(): Ape.EndpointResponse<PublicTypingStats> {
- return await this.httpClient.get(`${BASE_PATH}/typingStats`);
- }
-}
diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts
index 6bebc1c3c..1d14030a0 100644
--- a/frontend/src/ts/ape/index.ts
+++ b/frontend/src/ts/ape/index.ts
@@ -6,6 +6,7 @@ import { configsContract } from "@monkeytype/contracts/configs";
import { presetsContract } from "@monkeytype/contracts/presets";
import { apeKeysContract } from "@monkeytype/contracts/ape-keys";
import { psasContract } from "@monkeytype/contracts/psas";
+import { publicContract } from "@monkeytype/contracts/public";
const API_PATH = "";
const BASE_URL = envConfig.backendUrl;
@@ -22,7 +23,7 @@ const Ape = {
quotes: new endpoints.Quotes(httpClient),
leaderboards: new endpoints.Leaderboards(httpClient),
presets: buildClient(presetsContract, BASE_URL, 10_000),
- publicStats: new endpoints.Public(httpClient),
+ publicStats: buildClient(publicContract, BASE_URL, 10_000),
apeKeys: buildClient(apeKeysContract, BASE_URL, 10_000),
configuration: new endpoints.Configuration(httpClient),
dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)),
diff --git a/frontend/src/ts/modals/pb-tables.ts b/frontend/src/ts/modals/pb-tables.ts
index 736d87666..a78a8c9c8 100644
--- a/frontend/src/ts/modals/pb-tables.ts
+++ b/frontend/src/ts/modals/pb-tables.ts
@@ -34,7 +34,7 @@ function update(mode: Mode): void {
if (allmode2 === undefined) return;
const list: PBWithMode2[] = [];
- (Object.keys(allmode2) as Mode2<Mode>[]).forEach(function (key) {
+ Object.keys(allmode2).forEach(function (key) {
let pbs = allmode2[key] ?? [];
pbs = pbs.sort(function (a, b) {
return b.wpm - a.wpm;
diff --git a/frontend/src/ts/modals/share-test-settings.ts b/frontend/src/ts/modals/share-test-settings.ts
index 14bed5c51..ca6285fe4 100644
--- a/frontend/src/ts/modals/share-test-settings.ts
+++ b/frontend/src/ts/modals/share-test-settings.ts
@@ -43,7 +43,7 @@ function updateURL(): void {
}
if (getCheckboxValue("mode2")) {
- settings[1] = getMode2(Config, currentQuote) as Mode2<Mode>;
+ settings[1] = getMode2(Config, currentQuote);
}
if (getCheckboxValue("customText")) {
diff --git a/frontend/src/ts/pages/about.ts b/frontend/src/ts/pages/about.ts
index 6e93ebf05..af3328ca7 100644
--- a/frontend/src/ts/pages/about.ts
+++ b/frontend/src/ts/pages/about.ts
@@ -8,7 +8,10 @@ import * as ChartController from "../controllers/chart-controller";
import * as ConnectionState from "../states/connection";
import { intervalToDuration } from "date-fns/intervalToDuration";
import * as Skeleton from "../utils/skeleton";
-import { PublicTypingStats, SpeedHistogram } from "@monkeytype/shared-types";
+import {
+ TypingStats,
+ SpeedHistogram,
+} from "@monkeytype/contracts/schemas/public";
function reset(): void {
$(".pageAbout .contributors").empty();
@@ -19,7 +22,7 @@ function reset(): void {
}
let speedHistogramResponseData: SpeedHistogram | null;
-let typingStatsResponseData: PublicTypingStats | null;
+let typingStatsResponseData: TypingStats | null;
function updateStatsAndHistogram(): void {
if (speedHistogramResponseData) {
@@ -98,24 +101,26 @@ async function getStatsAndHistogramData(): Promise<void> {
}
const speedStats = await Ape.publicStats.getSpeedHistogram({
- language: "english",
- mode: "time",
- mode2: "60",
+ query: {
+ language: "english",
+ mode: "time",
+ mode2: "60",
+ },
});
- if (speedStats.status >= 200 && speedStats.status < 300) {
- speedHistogramResponseData = speedStats.data;
+ if (speedStats.status === 200) {
+ speedHistogramResponseData = speedStats.body.data;
} else {
Notifications.add(
- `Failed to get global speed stats for histogram: ${speedStats.message}`,
+ `Failed to get global speed stats for histogram: ${speedStats.body.message}`,
-1
);
}
const typingStats = await Ape.publicStats.getTypingStats();
- if (typingStats.status >= 200 && typingStats.status < 300) {
- typingStatsResponseData = typingStats.data;
+ if (typingStats.status === 200) {
+ typingStatsResponseData = typingStats.body.data;
} else {
Notifications.add(
- `Failed to get global typing stats: ${speedStats.message}`,
+ `Failed to get global typing stats: ${speedStats.body.message}`,
-1
);
}
diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts
index 29a6fab97..76533d8dd 100644
--- a/frontend/src/ts/test/pace-caret.ts
+++ b/frontend/src/ts/test/pace-caret.ts
@@ -8,7 +8,6 @@ import * as Numbers from "../utils/numbers";
import * as JSONData from "../utils/json-data";
import * as TestState from "./test-state";
import * as ConfigEvent from "../observables/config-event";
-import { Mode2 } from "@monkeytype/contracts/schemas/shared";
type Settings = {
wpm: number;
@@ -67,9 +66,7 @@ async function resetCaretPosition(): Promise<void> {
export async function init(): Promise<void> {
$("#paceCaret").addClass("hidden");
- const mode2 = Misc.getMode2(Config, TestWords.currentQuote) as Mode2<
- typeof Config.mode
- >;
+ const mode2 = Misc.getMode2(Config, TestWords.currentQuote);
let wpm;
if (Config.paceCaret === "pb") {
wpm = await DB.getLocalPB(
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index e7f383639..1224a0e19 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -4,6 +4,7 @@ import { apeKeysContract } from "./ape-keys";
import { configsContract } from "./configs";
import { presetsContract } from "./presets";
import { psasContract } from "./psas";
+import { publicContract } from "./public";
const c = initContract();
@@ -13,4 +14,5 @@ export const contract = c.router({
configs: configsContract,
presets: presetsContract,
psas: psasContract,
+ public: publicContract,
});
diff --git a/packages/contracts/src/public.ts b/packages/contracts/src/public.ts
new file mode 100644
index 000000000..93b2b81f9
--- /dev/null
+++ b/packages/contracts/src/public.ts
@@ -0,0 +1,70 @@
+import { initContract } from "@ts-rest/core";
+import { z } from "zod";
+import {
+ CommonResponses,
+ EndpointMetadata,
+ responseWithData,
+} from "./schemas/api";
+import { SpeedHistogramSchema, TypingStatsSchema } from "./schemas/public";
+import { Mode2Schema, ModeSchema } from "./schemas/shared";
+import { LanguageSchema } from "./schemas/util";
+
+export const GetSpeedHistogramQuerySchema = z
+ .object({
+ language: LanguageSchema,
+ mode: ModeSchema,
+ mode2: Mode2Schema,
+ })
+ .strict();
+export type GetSpeedHistogramQuery = z.infer<
+ typeof GetSpeedHistogramQuerySchema
+>;
+
+export const GetSpeedHistogramResponseSchema =
+ responseWithData(SpeedHistogramSchema);
+export type GetSpeedHistogramResponse = z.infer<
+ typeof GetSpeedHistogramResponseSchema
+>;
+
+export const GetTypingStatsResponseSchema = responseWithData(TypingStatsSchema);
+export type GetTypingStatsResponse = z.infer<
+ typeof GetTypingStatsResponseSchema
+>;
+
+const c = initContract();
+export const publicContract = c.router(
+ {
+ getSpeedHistogram: {
+ summary: "get speed histogram",
+ description:
+ "get number of users personal bests grouped by wpm level (multiples of ten)",
+ method: "GET",
+ path: "/speedHistogram",
+ query: GetSpeedHistogramQuerySchema,
+ responses: {
+ 200: GetSpeedHistogramResponseSchema,
+ },
+ },
+
+ getTypingStats: {
+ summary: "get typing stats",
+ description: "get number of tests and time users spend typing.",
+ method: "GET",
+ path: "/typingStats",
+ responses: {
+ 200: GetTypingStatsResponseSchema,
+ },
+ },
+ },
+ {
+ pathPrefix: "/public",
+ strictStatusCodes: true,
+ metadata: {
+ openApiTags: "public",
+ authenticationOptions: {
+ isPublic: true,
+ },
+ } as EndpointMetadata,
+ commonResponses: CommonResponses,
+ }
+);
diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts
index f90e47dbd..305d6100a 100644
--- a/packages/contracts/src/schemas/api.ts
+++ b/packages/contracts/src/schemas/api.ts
@@ -1,6 +1,12 @@
import { z, ZodSchema } from "zod";
-export type OpenApiTag = "configs" | "presets" | "ape-keys" | "admin" | "psas";
+export type OpenApiTag =
+ | "configs"
+ | "presets"
+ | "ape-keys"
+ | "admin"
+ | "psas"
+ | "public";
export type EndpointMetadata = {
/** Authentication options, by default a bearer token is required. */
diff --git a/packages/contracts/src/schemas/public.ts b/packages/contracts/src/schemas/public.ts
new file mode 100644
index 000000000..5a89122eb
--- /dev/null
+++ b/packages/contracts/src/schemas/public.ts
@@ -0,0 +1,15 @@
+import { z } from "zod";
+import { StringNumberSchema } from "./util";
+
+export const SpeedHistogramSchema = z.record(
+ StringNumberSchema,
+ z.number().int()
+);
+export type SpeedHistogram = z.infer<typeof SpeedHistogramSchema>;
+
+export const TypingStatsSchema = z.object({
+ timeTyping: z.number().nonnegative(),
+ testsCompleted: z.number().int().nonnegative(),
+ testsStarted: z.number().int().nonnegative(),
+});
+export type TypingStats = z.infer<typeof TypingStatsSchema>;
diff --git a/packages/contracts/src/schemas/shared.ts b/packages/contracts/src/schemas/shared.ts
index 0826839a8..5eb97b5d4 100644
--- a/packages/contracts/src/schemas/shared.ts
+++ b/packages/contracts/src/schemas/shared.ts
@@ -1,4 +1,4 @@
-import { z } from "zod";
+import { literal, z } from "zod";
import { StringNumberSchema } from "./util";
//used by config and shared
@@ -33,9 +33,19 @@ export const PersonalBestsSchema = z.object({
});
export type PersonalBests = z.infer<typeof PersonalBestsSchema>;
-//used by user and config
+//used by user, config, public
export const ModeSchema = PersonalBestsSchema.keyof();
export type Mode = z.infer<typeof ModeSchema>;
+
+export const Mode2Schema = z.union(
+ [StringNumberSchema, literal("zen"), literal("custom")],
+ {
+ errorMap: () => ({
+ message: 'Needs to be either a number, "zen" or "custom."',
+ }),
+ }
+);
+
export type Mode2<M extends Mode> = M extends M
? keyof PersonalBests[M]
: never;
diff --git a/packages/contracts/src/schemas/util.ts b/packages/contracts/src/schemas/util.ts
index 74e5ed963..25d9a4b77 100644
--- a/packages/contracts/src/schemas/util.ts
+++ b/packages/contracts/src/schemas/util.ts
@@ -1,8 +1,12 @@
import { z, ZodString } from "zod";
-export const StringNumberSchema = z.custom<`${number}`>((val) => {
- return typeof val === "string" ? /^\d+$/.test(val) : false;
-});
+export const StringNumberSchema = z
+
+ .custom<`${number}`>((val) => {
+ if (typeof val === "number") val = val.toString();
+ return typeof val === "string" ? /^\d+$/.test(val) : false;
+ }, 'Needs to be a number or a number represented as a string e.g. "10".')
+ .transform(String);
export type StringNumber = z.infer<typeof StringNumberSchema>;
@@ -13,3 +17,9 @@ export type Id = z.infer<typeof IdSchema>;
export const TagSchema = token().max(50);
export type Tag = z.infer<typeof TagSchema>;
+
+export const LanguageSchema = z
+ .string()
+ .max(50)
+ .regex(/^[a-zA-Z0-9_+]+$/);
+export type Language = z.infer<typeof LanguageSchema>;
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index fe8d50ea6..d069ecd3c 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -301,17 +301,6 @@ export type ResultFilters = {
} & Record<string, boolean>;
};
-export type SpeedHistogram = {
- [key: string]: number;
-};
-
-export type PublicTypingStats = {
- type: string;
- timeTyping: number;
- testsCompleted: number;
- testsStarted: number;
-};
-
export type LeaderboardEntry = {
_id: string;
wpm: number;