mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2024-09-20 07:16:17 +08:00
parent
6c9148624e
commit
c50535cd0f
144
backend/__tests__/api/controllers/public.spec.ts
Normal file
144
backend/__tests__/api/controllers/public.spec.ts
Normal file
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -66,6 +66,11 @@ export function getOpenApi(): OpenAPIObject {
|
|||
description: "Ape keys provide access to certain API endpoints.",
|
||||
"x-displayName": "Ape Keys",
|
||||
},
|
||||
{
|
||||
name: "public",
|
||||
description: "Public endpoints such as typing stats.",
|
||||
"x-displayName": "public",
|
||||
},
|
||||
{
|
||||
name: "psas",
|
||||
description: "Public service announcements.",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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()],
|
||||
});
|
||||
|
|
|
@ -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";
|
||||
import * as PublicController from "../controllers/public";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
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),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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`);
|
||||
}
|
||||
}
|
|
@ -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)),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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")) {
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
70
packages/contracts/src/public.ts
Normal file
70
packages/contracts/src/public.ts
Normal file
|
@ -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,
|
||||
}
|
||||
);
|
|
@ -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. */
|
||||
|
|
15
packages/contracts/src/schemas/public.ts
Normal file
15
packages/contracts/src/schemas/public.ts
Normal file
|
@ -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>;
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue