Validation and more api endpoints

This commit is contained in:
2025-11-06 11:33:35 -08:00
parent 279f08a9dd
commit e83746d6d8
4 changed files with 156 additions and 7 deletions

12
package-lock.json generated
View File

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

View File

@@ -50,6 +50,7 @@
},
"dependencies": {
"axios": "^1.13.2",
"consola": "^3.4.2"
"consola": "^3.4.2",
"zod": "^4.1.12"
}
}

View File

@@ -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<boolean>
async login(options: LoginRequest): Promise<boolean>
{
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<User | RequestError>("/login", { email, password });
const response = await this._axios.post<User | RequestError>("/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<boolean>
{
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<null | RequestError>("/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<User | null>
{
// 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<User | RequestError>("/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<boolean>
{
const response = await this._axios.get("/up");
return response.status === 200;
}
async createListing(options: CreateListingRequest): Promise<number | null>
{
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<Listing | RequestError>("/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<Array<Listing>>
{
// TODO: account for pagination and filtering options
const response = await this._axios.
}
}

46
src/lib/requests.ts Normal file
View File

@@ -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<typeof LoginRequestSchema>;
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<typeof RegisterRequestSchema>;
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<typeof CreateListingRequestSchema>;
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<typeof UpdateListingRequestSchema>;