impr: use tsrest for public endpoints (@fehmer) (#5716)

!nuf
This commit is contained in:
Christian Fehmer 2024-08-09 12:39:27 +02:00 committed by GitHub
parent 6c9148624e
commit c50535cd0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 349 additions and 132 deletions

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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. */

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

View file

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

View file

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

View file

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