Validation and more api endpoints
This commit is contained in:
12
package-lock.json
generated
12
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"consola": "^3.4.2"
|
||||
"consola": "^3.4.2",
|
||||
"zod": "^4.1.12"
|
||||
}
|
||||
}
|
||||
|
||||
102
src/lib/api.ts
102
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<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
46
src/lib/requests.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user