From e83746d6d8092303f6165731da6b2a769b0a7f15 Mon Sep 17 00:00:00 2001 From: James Shiffer Date: Thu, 6 Nov 2025 11:33:35 -0800 Subject: [PATCH] Validation and more api endpoints --- package-lock.json | 12 +++++- package.json | 3 +- src/lib/api.ts | 102 +++++++++++++++++++++++++++++++++++++++++--- src/lib/requests.ts | 46 ++++++++++++++++++++ 4 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 src/lib/requests.ts diff --git a/package-lock.json b/package-lock.json index a8efd9e..73c2664 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.1", "dependencies": { "axios": "^1.13.2", - "consola": "^3.4.2" + "consola": "^3.4.2", + "zod": "^4.1.12" }, "devDependencies": { "@chromatic-com/storybook": "^4.1.2", @@ -5906,6 +5907,15 @@ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "dev": true, "license": "MIT" + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 899c6ea..ff50af3 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ }, "dependencies": { "axios": "^1.13.2", - "consola": "^3.4.2" + "consola": "^3.4.2", + "zod": "^4.1.12" } } diff --git a/src/lib/api.ts b/src/lib/api.ts index ba42072..f0d9930 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,7 +1,7 @@ import { Axios } from 'axios'; import { consola } from 'consola/browser'; -import type { User } from './models'; - +import type { Listing, User } from './models'; +import { CreateListingRequestSchema, LoginRequestSchema, RegisterRequestSchema, type CreateListingRequest, type LoginRequest, type RegisterRequest } from './requests'; interface RequestError { @@ -12,6 +12,7 @@ class HeaptraderAPI { private _axios: Axios; private _user?: User; + private _lastUserUpdateTimeMs?: number; constructor(baseURL: string = "http://localhost") { @@ -27,12 +28,18 @@ class HeaptraderAPI await this._axios.get("/sanctum/csrf-cookie"); } - async login(email: string, password: string): Promise + async login(options: LoginRequest): Promise { + const validated = LoginRequestSchema.safeParse(options); + if (!validated.success) { + consola.error("Login request was invalid: " + validated.error.message); + return false; + } await this.csrfCookie(); - const response = await this._axios.post("/login", { email, password }); + const response = await this._axios.post("/login", validated.data); if (response.status === 200) { this._user = response.data as User; + this._lastUserUpdateTimeMs = Date.now(); consola.info("Logged in as " + this._user.name); return true; } else { @@ -41,8 +48,93 @@ class HeaptraderAPI } } - user(): User | null + async register(options: RegisterRequest): Promise { + const validated = RegisterRequestSchema.safeParse(options); + if (!validated.success) { + consola.error("Register request was invalid: " + validated.error.message); + return false; + } + await this.csrfCookie(); + const response = await this._axios.post("/register", validated.data); + if (response.status === 201) { + await this.refreshUser(); + consola.info("Registered as " + this._user?.name); + return true; + } else { + consola.error("Failed to register: " + (response.data as RequestError).message); + return false; + } + } + + async user(): Promise + { + // TTL on cached user is 5 minutes + if (!this._lastUserUpdateTimeMs || Date.now() - this._lastUserUpdateTimeMs > 5 * 60 * 1000) { + await this.refreshUser(); + this._lastUserUpdateTimeMs = Date.now(); + } return this._user ?? null; } + + async refreshUser() + { + // Do up to one retry + for (let i = 0; i < 2; i++) + { + const response = await this._axios.get("/api/user"); + if (response.status === 200) { + this._user = response.data as User; + return; + } else if (response.status === 419) { + consola.warn("Error 419 occurred while refreshing user info, fetching new CSRF cookie..."); + await this.csrfCookie(); + } else if (response.status === 403) { + consola.error("Error 403 occurred while refreshing user info, we must be logged out."); + this._user = undefined; + return; + } else { + const error = response.data as RequestError; + consola.error("Error " + response.status + " while refreshing user info: " + error.message); + } + // Wait 1 second for retry + consola.info("Retrying get user request..."); + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + async heartbeat(): Promise + { + const response = await this._axios.get("/up"); + return response.status === 200; + } + + async createListing(options: CreateListingRequest): Promise + { + const validated = CreateListingRequestSchema.safeParse(options); + if (!validated.success) { + consola.error("Create listing request was invalid: " + validated.error.message); + return null; + } + const response = await this._axios.post("/api/listing", validated.data); + if (response.status === 200) { + return (response.data as Listing).id; + } else if (response.status === 403) { + consola.error("Create listing request failed: 403 Forbidden."); + if (!this._user?.email_verified_at) { + consola.error("Your email is not yet verified, so you cannot create listings."); + } + return null; + } else { + const error = response.data as RequestError; + consola.error("Create listing request was rejected by server (" + response.status + "): " + error.message); + return null; + } + } + + async getHomepageListings(): Promise> + { + // TODO: account for pagination and filtering options + const response = await this._axios. + } } diff --git a/src/lib/requests.ts b/src/lib/requests.ts new file mode 100644 index 0000000..fbb3618 --- /dev/null +++ b/src/lib/requests.ts @@ -0,0 +1,46 @@ +import { ItemCondition } from './models'; +import * as z from 'zod'; + +export const LoginRequestSchema = z.object({ + email: z.email(), + password: z.string(), +}); + +export type LoginRequest = z.infer; + +export const RegisterRequestSchema = z.object({ + name: z.string().max(50), + email: z.email(), + password: z.string().max(100), + password_confirmation: z.string().max(100), +}).superRefine(({ password, password_confirmation }, ctx) => { + if (password !== password_confirmation) { + ctx.addIssue({ + code: "custom", + message: "The passwords did not match", + path: ["password_confirmation"] + }); + } +}); + +export type RegisterRequest = z.infer; + +export const CreateListingRequestSchema = z.object({ + title: z.string().max(100).nonoptional(), + description: z.string().max(1000).nonoptional(), + condition: z.enum(Object.values(ItemCondition)).optional(), + price: z.number().nonnegative().nonoptional(), + location: z.string().max(50).optional(), +}); + +export type CreateListingRequest = z.infer; + +export const UpdateListingRequestSchema = z.object({ + title: z.string().max(100).optional(), + description: z.string().max(1000).optional(), + condition: z.enum(Object.values(ItemCondition)).optional(), + price: z.number().nonnegative().optional(), + location: z.string().max(50).optional(), +}); + +export type UpdateListingRequest = z.infer;