From 060c8e89630503ec3d6f2931ff8ebf5ebe54cf34 Mon Sep 17 00:00:00 2001 From: Ben Allenden Date: Fri, 3 Oct 2025 17:54:59 +0100 Subject: [PATCH 01/13] Move openapi client instantiation to utils/api.ts --- apps/client/src/libs/api/index.ts | 11 ++--------- apps/client/src/utils/api.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/client/src/libs/api/index.ts b/apps/client/src/libs/api/index.ts index 7ad69910..62d4d230 100644 --- a/apps/client/src/libs/api/index.ts +++ b/apps/client/src/libs/api/index.ts @@ -1,15 +1,8 @@ -import createClient from "openapi-fetch" +import createOpenApiClient from "openapi-fetch" import type { components, paths } from "./v1" -const BASE_URL = import.meta.env.VITE_API_BASE_URL - -// TODO: validate .env variables in one place -if (!BASE_URL) { - throw new Error("API base URL missing from .env file!") -} - -export const client = createClient({ baseUrl: BASE_URL }) +export const createClient = (baseUrl: string) => createOpenApiClient({ baseUrl }) export type { paths, operations } from "./v1" export type schemas = components["schemas"] diff --git a/apps/client/src/utils/api.ts b/apps/client/src/utils/api.ts index c73f1377..237b7f97 100644 --- a/apps/client/src/utils/api.ts +++ b/apps/client/src/utils/api.ts @@ -1,5 +1,16 @@ import { QueryClient } from "@tanstack/react-query" +import { createClient } from "@/libs/api" + +const BASE_URL = import.meta.env.VITE_API_BASE_URL + +// TODO: validate .env variables in one place +if (!BASE_URL) { + throw new Error("API base URL missing from .env file!") +} + +export const client = createClient(BASE_URL) + export const queryClient = new QueryClient({ defaultOptions: {} }) From 2c3a21980d8e3f6dacb448b54cb3140805e1a1ff Mon Sep 17 00:00:00 2001 From: Ben Allenden Date: Fri, 3 Oct 2025 17:56:35 +0100 Subject: [PATCH 02/13] Move queryClient instantiation to utils/query.ts --- apps/client/src/providers/query-client-provider.tsx | 2 +- apps/client/src/utils/api.ts | 6 ------ apps/client/src/utils/query.ts | 8 ++++++++ apps/client/src/utils/router.ts | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 apps/client/src/utils/query.ts diff --git a/apps/client/src/providers/query-client-provider.tsx b/apps/client/src/providers/query-client-provider.tsx index 7cb94e30..7876bbc5 100644 --- a/apps/client/src/providers/query-client-provider.tsx +++ b/apps/client/src/providers/query-client-provider.tsx @@ -1,6 +1,6 @@ import { QueryClientProvider } from "@tanstack/react-query" -import { queryClient } from "@/utils/api" +import { queryClient } from "@/utils/query" interface Props { children: React.ReactNode diff --git a/apps/client/src/utils/api.ts b/apps/client/src/utils/api.ts index 237b7f97..5c323c5f 100644 --- a/apps/client/src/utils/api.ts +++ b/apps/client/src/utils/api.ts @@ -1,5 +1,3 @@ -import { QueryClient } from "@tanstack/react-query" - import { createClient } from "@/libs/api" const BASE_URL = import.meta.env.VITE_API_BASE_URL @@ -10,7 +8,3 @@ if (!BASE_URL) { } export const client = createClient(BASE_URL) - -export const queryClient = new QueryClient({ - defaultOptions: {} -}) diff --git a/apps/client/src/utils/query.ts b/apps/client/src/utils/query.ts new file mode 100644 index 00000000..88b71a79 --- /dev/null +++ b/apps/client/src/utils/query.ts @@ -0,0 +1,8 @@ +import { QueryClient } from "@tanstack/react-query" + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: {}, + mutations: {} + } +}) diff --git a/apps/client/src/utils/router.ts b/apps/client/src/utils/router.ts index 29cdeee1..a6708213 100644 --- a/apps/client/src/utils/router.ts +++ b/apps/client/src/utils/router.ts @@ -2,7 +2,7 @@ import { createRouter } from "@tanstack/react-router" import { routeTree } from "@/routeTree.gen" -import { queryClient } from "./api" +import { queryClient } from "./query" declare module "@tanstack/react-router" { interface Register { From bab4225f20998b022f154576d6bf38edaf084a3e Mon Sep 17 00:00:00 2001 From: Ben Allenden Date: Fri, 3 Oct 2025 17:57:31 +0100 Subject: [PATCH 03/13] Generate most recent openapi spec --- apps/client/src/libs/api/v1.d.ts | 294 +++++++++++++++++++++++++++---- 1 file changed, 260 insertions(+), 34 deletions(-) diff --git a/apps/client/src/libs/api/v1.d.ts b/apps/client/src/libs/api/v1.d.ts index 8147e2f7..d2ca2fdf 100644 --- a/apps/client/src/libs/api/v1.d.ts +++ b/apps/client/src/libs/api/v1.d.ts @@ -11,7 +11,9 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get your profile */ get: operations["getMe"]; + /** @description Update your profile */ put: operations["updateProfile"]; post?: never; delete?: never; @@ -29,7 +31,9 @@ export interface paths { }; get?: never; put?: never; + /** @description Follow a profile by ID */ post: operations["followById"]; + /** @description Unfollow a profile by ID */ delete: operations["unfollowById"]; options?: never; head?: never; @@ -45,6 +49,7 @@ export interface paths { }; get?: never; put?: never; + /** @description Create a post */ post: operations["create"]; delete?: never; options?: never; @@ -61,7 +66,9 @@ export interface paths { }; get?: never; put?: never; + /** @description Like a post by ID */ post: operations["like"]; + /** @description Unlike a post by ID */ delete: operations["unlike"]; options?: never; head?: never; @@ -77,6 +84,7 @@ export interface paths { }; get?: never; put?: never; + /** @description Sync the authenticated Clerk user to the local application */ post: operations["clerkOnboarding"]; delete?: never; options?: never; @@ -91,6 +99,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get a profile by username */ get: operations["getByUsername"]; put?: never; post?: never; @@ -107,6 +116,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get following by ID */ get: operations["getFollowing"]; put?: never; post?: never; @@ -123,6 +133,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get followers by ID */ get: operations["getFollowers"]; put?: never; post?: never; @@ -139,9 +150,11 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get a post by ID */ get: operations["getPostById"]; put?: never; post?: never; + /** @description Delete a post by ID */ delete: operations["delete"]; options?: never; head?: never; @@ -155,6 +168,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get post replies by ID */ get: operations["getRepliesByPostId"]; put?: never; post?: never; @@ -171,6 +185,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get replies by profile ID */ get: operations["getProfileReplies"]; put?: never; post?: never; @@ -187,6 +202,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get posts by profile ID */ get: operations["getProfilePosts"]; put?: never; post?: never; @@ -203,6 +219,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get mentions of profile ID */ get: operations["getProfileMentions"]; put?: never; post?: never; @@ -219,6 +236,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get likes by profile ID */ get: operations["getProfileLikes"]; put?: never; post?: never; @@ -235,6 +253,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get your homepage posts */ get: operations["getHomeFeed"]; put?: never; post?: never; @@ -251,6 +270,7 @@ export interface paths { path?: never; cookie?: never; }; + /** @description Get discover page posts */ get: operations["getDiscoverFeed"]; put?: never; post?: never; @@ -270,6 +290,14 @@ export interface components { bio?: string; location?: string; }; + ErrorResponse: { + /** Format: date-time */ + timestamp?: string; + /** Format: int32 */ + status?: number; + message?: string; + path?: string; + }; /** @description Represents the request body required to create a new post. */ CreatePostRequest: { /** Format: uuid */ @@ -426,13 +454,22 @@ export interface operations { }; }; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description Invalid field(s) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; followById: { @@ -446,13 +483,31 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Already following profile with ID */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; unfollowById: { @@ -466,13 +521,22 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; create: { @@ -488,13 +552,31 @@ export interface operations { }; }; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description Invalid field(s) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Parent ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; like: { @@ -508,13 +590,31 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Already liked post with ID */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; unlike: { @@ -528,8 +628,8 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; @@ -546,8 +646,8 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description Created */ + 201: { headers: { [name: string]: unknown; }; @@ -577,13 +677,24 @@ export interface operations { "*/*": components["schemas"]["Profile"]; }; }; + /** @description Username not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getFollowing: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -602,13 +713,33 @@ export interface operations { "*/*": components["schemas"]["PagedSimplifiedProfile"]; }; }; + /** @description Invalid pagination parameter(s) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getFollowers: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -627,6 +758,24 @@ export interface operations { "*/*": components["schemas"]["PagedSimplifiedProfile"]; }; }; + /** @description Invalid pagination parameter(s) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getPostById: { @@ -649,6 +798,15 @@ export interface operations { "*/*": components["schemas"]["Post"]; }; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; delete: { @@ -662,20 +820,31 @@ export interface operations { }; requestBody?: never; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; content?: never; }; + /** @description Cannot delete another user's post */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getRepliesByPostId: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -694,13 +863,24 @@ export interface operations { "*/*": components["schemas"]["PagedPost"]; }; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getProfileReplies: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -719,13 +899,24 @@ export interface operations { "*/*": components["schemas"]["PagedPost"]; }; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getProfilePosts: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -744,13 +935,24 @@ export interface operations { "*/*": components["schemas"]["PagedPost"]; }; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getProfileMentions: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -769,13 +971,24 @@ export interface operations { "*/*": components["schemas"]["PagedPost"]; }; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getProfileLikes: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path: { @@ -794,13 +1007,24 @@ export interface operations { "*/*": components["schemas"]["PagedPost"]; }; }; + /** @description ID not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ErrorResponse"]; + }; + }; }; }; getHomeFeed: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path?: never; @@ -822,8 +1046,10 @@ export interface operations { getDiscoverFeed: { parameters: { query?: { - offset?: number; - limit?: number; + /** @description Zero-based offset */ + offset?: string; + /** @description Page size */ + limit?: string; }; header?: never; path?: never; From 7acb538481374ea365b9667325ed1aa64b8dfe76 Mon Sep 17 00:00:00 2001 From: Ben Allenden Date: Fri, 3 Oct 2025 17:59:36 +0100 Subject: [PATCH 04/13] Install sonner toast --- apps/client/package-lock.json | 11 +++++++++++ apps/client/package.json | 1 + apps/client/src/providers/index.tsx | 6 +++++- apps/client/src/providers/sonner-provider.tsx | 5 +++++ 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/providers/sonner-provider.tsx diff --git a/apps/client/package-lock.json b/apps/client/package-lock.json index 7c8e807a..0df209e1 100644 --- a/apps/client/package-lock.json +++ b/apps/client/package-lock.json @@ -20,6 +20,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-icons": "^5.5.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11" }, @@ -5434,6 +5435,16 @@ "seroval-plugins": "~1.3.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", diff --git a/apps/client/package.json b/apps/client/package.json index dc273f2a..e165cec2 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -27,6 +27,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-icons": "^5.5.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11" }, diff --git a/apps/client/src/providers/index.tsx b/apps/client/src/providers/index.tsx index beecf00f..3522ed37 100644 --- a/apps/client/src/providers/index.tsx +++ b/apps/client/src/providers/index.tsx @@ -1,5 +1,6 @@ import { EchoClerkProvider } from "./clerk-provider" import { EchoQueryClientProvider } from "./query-client-provider" +import SonnerProvider from "./sonner-provider" interface Props { children: React.ReactNode @@ -8,7 +9,10 @@ interface Props { export function AppProvider({ children }: Readonly) { return ( - {children} + + {children} + + ) } diff --git a/apps/client/src/providers/sonner-provider.tsx b/apps/client/src/providers/sonner-provider.tsx new file mode 100644 index 00000000..49114933 --- /dev/null +++ b/apps/client/src/providers/sonner-provider.tsx @@ -0,0 +1,5 @@ +import { Toaster } from "sonner" + +export default function SonnerProvider() { + return +} From ee1c6b6442a4a74d482526d6b18005d1b3fedd51 Mon Sep 17 00:00:00 2001 From: Ben Allenden Date: Fri, 3 Oct 2025 18:00:13 +0100 Subject: [PATCH 05/13] Fix import --- apps/client/src/routes/(protected)/home.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/routes/(protected)/home.tsx b/apps/client/src/routes/(protected)/home.tsx index 4cf8f3ce..a3cf8f8a 100644 --- a/apps/client/src/routes/(protected)/home.tsx +++ b/apps/client/src/routes/(protected)/home.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react" import { UserButton, useAuth } from "@clerk/clerk-react" import { createFileRoute } from "@tanstack/react-router" -import { client } from "@/libs/api" +import { client } from "@/utils/api" export const Route = createFileRoute("/(protected)/home")({ component: HomePage From 23319b55b8361a466d01114aee443b4fddb5fbd0 Mon Sep 17 00:00:00 2001 From: Ben Allenden Date: Fri, 3 Oct 2025 18:01:03 +0100 Subject: [PATCH 06/13] Update libs/api exports --- apps/client/src/libs/api/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/client/src/libs/api/index.ts b/apps/client/src/libs/api/index.ts index 62d4d230..ef1f9e97 100644 --- a/apps/client/src/libs/api/index.ts +++ b/apps/client/src/libs/api/index.ts @@ -4,5 +4,7 @@ import type { components, paths } from "./v1" export const createClient = (baseUrl: string) => createOpenApiClient({ baseUrl }) -export type { paths, operations } from "./v1" +export type { Middleware } from "openapi-fetch" +export type { operations, paths } from "./v1" export type schemas = components["schemas"] +export type Client = ReturnType From 7a147ecefd5540a53aec878c151e9c715b56f4d8 Mon Sep 17 00:00:00 2001 From: Ben Allenden Date: Fri, 3 Oct 2025 18:03:40 +0100 Subject: [PATCH 07/13] Add client error middleware w/ error toast for network issues --- apps/client/src/utils/api.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/client/src/utils/api.ts b/apps/client/src/utils/api.ts index 5c323c5f..18d8da33 100644 --- a/apps/client/src/utils/api.ts +++ b/apps/client/src/utils/api.ts @@ -1,4 +1,6 @@ -import { createClient } from "@/libs/api" +import { toast } from "sonner" + +import { type Middleware, createClient } from "@/libs/api" const BASE_URL = import.meta.env.VITE_API_BASE_URL @@ -7,4 +9,19 @@ if (!BASE_URL) { throw new Error("API base URL missing from .env file!") } -export const client = createClient(BASE_URL) +const errorMiddleware: Middleware = { + onResponse: () => { + console.log("onResponse") + }, + onError: () => { + toast.error("A network error occurred", { description: "Please try again later" }) + } +} + +function createClientWithMiddleware() { + const client = createClient(BASE_URL) + client.use(errorMiddleware) + return client +} + +export const client = createClientWithMiddleware() From 28db2ff21b8b73d893a0c895939ea60b91d7e9e1 Mon Sep 17 00:00:00 2001 From: Ben Allenden Date: Fri, 3 Oct 2025 18:54:04 +0100 Subject: [PATCH 08/13] Pull latest openapi spec --- apps/client/src/libs/api/v1.d.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/client/src/libs/api/v1.d.ts b/apps/client/src/libs/api/v1.d.ts index d2ca2fdf..2090b36c 100644 --- a/apps/client/src/libs/api/v1.d.ts +++ b/apps/client/src/libs/api/v1.d.ts @@ -292,11 +292,11 @@ export interface components { }; ErrorResponse: { /** Format: date-time */ - timestamp?: string; + timestamp: string; /** Format: int32 */ - status?: number; - message?: string; - path?: string; + status: number; + message: string; + path: string; }; /** @description Represents the request body required to create a new post. */ CreatePostRequest: { From f5114e2cbc333b99a71c5d6e9b9ffd0c738407bc Mon Sep 17 00:00:00 2001 From: Ben Allenden Date: Fri, 3 Oct 2025 18:54:17 +0100 Subject: [PATCH 09/13] Update script name --- apps/client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/package.json b/apps/client/package.json index e165cec2..a1f61a95 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -11,7 +11,7 @@ "lint": "npx eslint .", "lint:fix": "npx eslint . --fix", "typecheck": "npx tsc --noEmit", - "generate": "npx openapi-typescript https://api.echo-social.app/openapi -o ./src/libs/api/v1.d.ts", + "openapi": "npx openapi-typescript https://api.echo-social.app/openapi -o ./src/libs/api/v1.d.ts", "preview": "vite preview" }, "dependencies": { From 54fb11068264260e234d0ddb0407464fcaf5b79f Mon Sep 17 00:00:00 2001 From: Ben Allenden Date: Fri, 3 Oct 2025 19:01:33 +0100 Subject: [PATCH 10/13] Add ApiException class export to libs/api --- apps/client/src/libs/api/index.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/client/src/libs/api/index.ts b/apps/client/src/libs/api/index.ts index ef1f9e97..2b320736 100644 --- a/apps/client/src/libs/api/index.ts +++ b/apps/client/src/libs/api/index.ts @@ -4,6 +4,22 @@ import type { components, paths } from "./v1" export const createClient = (baseUrl: string) => createOpenApiClient({ baseUrl }) +export class ApiException extends Error { + public timestamp: string + public status: number + public path: string + public response: Response + + constructor(error: schemas["ErrorResponse"], response: Response) { + super(error.message) + this.timestamp = error.timestamp + this.status = error.status + this.path = error.path + this.response = response + this.name = "ApiException" + } +} + export type { Middleware } from "openapi-fetch" export type { operations, paths } from "./v1" export type schemas = components["schemas"] From 214df61bdce52ed29c202978e88ccff98ffd24c1 Mon Sep 17 00:00:00 2001 From: Ben Allenden Date: Fri, 3 Oct 2025 19:02:00 +0100 Subject: [PATCH 11/13] Add onResponse error middleware to throw when non-2xx responses are returned --- apps/client/src/utils/api.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/client/src/utils/api.ts b/apps/client/src/utils/api.ts index 18d8da33..6691f645 100644 --- a/apps/client/src/utils/api.ts +++ b/apps/client/src/utils/api.ts @@ -1,6 +1,6 @@ import { toast } from "sonner" -import { type Middleware, createClient } from "@/libs/api" +import { ApiException, type Middleware, createClient } from "@/libs/api" const BASE_URL = import.meta.env.VITE_API_BASE_URL @@ -10,8 +10,11 @@ if (!BASE_URL) { } const errorMiddleware: Middleware = { - onResponse: () => { - console.log("onResponse") + onResponse: async ({ response }) => { + if (response.ok) return response + + const error = await response.json() + throw new ApiException(error, response) // https://openapi-ts.dev/openapi-fetch/middleware-auth#throwing }, onError: () => { toast.error("A network error occurred", { description: "Please try again later" }) From a833c1279e9e731b2dff556ac1bb36399d6071e3 Mon Sep 17 00:00:00 2001 From: Ben Allenden Date: Fri, 3 Oct 2025 19:57:49 +0100 Subject: [PATCH 12/13] Install Clerk types --- apps/client/package-lock.json | 7 ++++--- apps/client/package.json | 1 + apps/client/src/types/global.d.ts | 4 ++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/client/package-lock.json b/apps/client/package-lock.json index 0df209e1..f5eeb961 100644 --- a/apps/client/package-lock.json +++ b/apps/client/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.1", "dependencies": { "@clerk/clerk-react": "^5.35.3", + "@clerk/types": "^4.90.0", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-form": "^1.15.0", "@tanstack/react-query": "^5.90.2", @@ -577,9 +578,9 @@ } }, "node_modules/@clerk/types": { - "version": "4.68.0", - "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.68.0.tgz", - "integrity": "sha512-3+PoGGQgyzLZibzYleByPmx6dfPVoFUjLBy+kASHzF9g3o12WchDn1aI01vN7WNm1ik1Z5sbLhzJs8d9bz38EQ==", + "version": "4.90.0", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.90.0.tgz", + "integrity": "sha512-AnXeCkyFdM+3icdeKElajVKV73IvlQ6eeWTaLG700CzL+Zw9iD2YV4t8qAhNhwo0FFtkcI9EKDblw6G9Rn1Onw==", "license": "MIT", "dependencies": { "csstype": "3.1.3" diff --git a/apps/client/package.json b/apps/client/package.json index a1f61a95..0d7f305e 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@clerk/clerk-react": "^5.35.3", + "@clerk/types": "^4.90.0", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-form": "^1.15.0", "@tanstack/react-query": "^5.90.2", diff --git a/apps/client/src/types/global.d.ts b/apps/client/src/types/global.d.ts index 6bafd05d..efc0a80f 100644 --- a/apps/client/src/types/global.d.ts +++ b/apps/client/src/types/global.d.ts @@ -1,4 +1,8 @@ +import type { Clerk } from "@clerk/clerk-js" + declare global { + var Clerk: Clerk + interface CustomJwtSessionClaims { echo_id: string | null onboarded: boolean From 5a8b2180b7dafa02655bfbb67ce36cd657faa128 Mon Sep 17 00:00:00 2001 From: Ben Allenden Date: Fri, 3 Oct 2025 20:03:16 +0100 Subject: [PATCH 13/13] Add client auth middleware to inject token --- apps/client/src/utils/api.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/client/src/utils/api.ts b/apps/client/src/utils/api.ts index 6691f645..8173e30f 100644 --- a/apps/client/src/utils/api.ts +++ b/apps/client/src/utils/api.ts @@ -9,6 +9,16 @@ if (!BASE_URL) { throw new Error("API base URL missing from .env file!") } +const authMiddleware: Middleware = { + onRequest: async ({ request }) => { + const token = await globalThis.Clerk?.session?.getToken() + + if (token) { + request.headers.append("Authorization", `Bearer ${token}`) + } + } +} + const errorMiddleware: Middleware = { onResponse: async ({ response }) => { if (response.ok) return response @@ -23,6 +33,7 @@ const errorMiddleware: Middleware = { function createClientWithMiddleware() { const client = createClient(BASE_URL) + client.use(authMiddleware) client.use(errorMiddleware) return client }