From c02f6dc2f207b2c8a09de77ee2d8a7c93cc4a3a7 Mon Sep 17 00:00:00 2001 From: zuiidea Date: Sun, 18 Oct 2026 21:12:02 +0800 Subject: [PATCH] refactor(api): split modules under shared/ with API_PREFIX Move auth and users into folder modules, centralize path constants, and align MSW with endpoint paths in both template apps. Co-authored-by: Cursor --- .../.agents/skills/add-resource/SKILL.md | 4 +- apps/basic/AGENTS.md | 2 +- apps/basic/README.md | 2 +- apps/basic/src/api/{auth.ts => auth/index.ts} | 17 +- apps/basic/src/api/auth/paths.ts | 12 ++ apps/basic/src/api/auth/schemas.ts | 48 +++++ apps/basic/src/api/request.ts | 8 +- apps/basic/src/api/schemas.ts | 184 +----------------- apps/basic/src/api/shared/envelope.ts | 26 +++ apps/basic/src/api/shared/index.ts | 5 + apps/basic/src/api/shared/menu.ts | 37 ++++ apps/basic/src/api/shared/pagination.ts | 14 ++ apps/basic/src/api/shared/params.ts | 16 ++ apps/basic/src/api/shared/prefix.ts | 2 + .../basic/src/api/{user.ts => users/index.ts} | 30 +-- apps/basic/src/api/users/schemas.ts | 45 +++++ apps/basic/src/mocks/handlers/auth.ts | 13 +- apps/basic/src/mocks/handlers/user.ts | 9 +- apps/basic/src/routes/_auth/users/index.tsx | 2 +- .../.agents/skills/add-resource/SKILL.md | 4 +- apps/with-lingui/AGENTS.md | 2 +- apps/with-lingui/README.md | 2 +- .../src/api/{auth.ts => auth/index.ts} | 17 +- apps/with-lingui/src/api/auth/paths.ts | 12 ++ apps/with-lingui/src/api/auth/schemas.ts | 48 +++++ apps/with-lingui/src/api/request.ts | 8 +- apps/with-lingui/src/api/schemas.ts | 183 +---------------- apps/with-lingui/src/api/shared/envelope.ts | 26 +++ apps/with-lingui/src/api/shared/index.ts | 5 + apps/with-lingui/src/api/shared/menu.ts | 37 ++++ apps/with-lingui/src/api/shared/pagination.ts | 14 ++ apps/with-lingui/src/api/shared/params.ts | 16 ++ apps/with-lingui/src/api/shared/prefix.ts | 2 + .../src/api/{user.ts => users/index.ts} | 30 +-- apps/with-lingui/src/api/users/schemas.ts | 45 +++++ apps/with-lingui/src/mocks/handlers/auth.ts | 13 +- apps/with-lingui/src/mocks/handlers/user.ts | 9 +- .../src/routes/_auth/users/index.tsx | 2 +- 38 files changed, 504 insertions(+), 447 deletions(-) rename apps/basic/src/api/{auth.ts => auth/index.ts} (83%) create mode 100644 apps/basic/src/api/auth/paths.ts create mode 100644 apps/basic/src/api/auth/schemas.ts create mode 100644 apps/basic/src/api/shared/envelope.ts create mode 100644 apps/basic/src/api/shared/index.ts create mode 100644 apps/basic/src/api/shared/menu.ts create mode 100644 apps/basic/src/api/shared/pagination.ts create mode 100644 apps/basic/src/api/shared/params.ts create mode 100644 apps/basic/src/api/shared/prefix.ts rename apps/basic/src/api/{user.ts => users/index.ts} (63%) create mode 100644 apps/basic/src/api/users/schemas.ts rename apps/with-lingui/src/api/{auth.ts => auth/index.ts} (83%) create mode 100644 apps/with-lingui/src/api/auth/paths.ts create mode 100644 apps/with-lingui/src/api/auth/schemas.ts create mode 100644 apps/with-lingui/src/api/shared/envelope.ts create mode 100644 apps/with-lingui/src/api/shared/index.ts create mode 100644 apps/with-lingui/src/api/shared/menu.ts create mode 100644 apps/with-lingui/src/api/shared/pagination.ts create mode 100644 apps/with-lingui/src/api/shared/params.ts create mode 100644 apps/with-lingui/src/api/shared/prefix.ts rename apps/with-lingui/src/api/{user.ts => users/index.ts} (63%) create mode 100644 apps/with-lingui/src/api/users/schemas.ts diff --git a/apps/basic/.agents/skills/add-resource/SKILL.md b/apps/basic/.agents/skills/add-resource/SKILL.md index 8871f05..5816201 100644 --- a/apps/basic/.agents/skills/add-resource/SKILL.md +++ b/apps/basic/.agents/skills/add-resource/SKILL.md @@ -17,14 +17,14 @@ vp check --no-fmt pnpm run test:e2e -- e2e/.spec.ts ``` -## 1. Zod and types (`src/api/schemas.ts`) +## 1. Zod and types (`src/api//schemas.ts`) - Define `Schema`, `CreateRequestSchema`, `UpdateRequestSchema` (`Update` = `Create.partial()`). - For list `output`, reuse `PaginatedDataSchema(Schema)` (unwrapped `data`; envelope stays in `request.ts`). - Add list query schema (e.g. `ListQuerySchema`) when the table has filters/pagination. - Export `type = z.infer`. -## 2. Resource API module (`src/api/.ts`) +## 2. Resource API module (`src/api//index.ts`) - Export path constants if useful for MSW/docs. - Define APIs with `defineApi` from `@/api/request`: `list` / `create` / `update` / `delete` (and `queryKeys`). diff --git a/apps/basic/AGENTS.md b/apps/basic/AGENTS.md index a4bd5d2..159fe07 100644 --- a/apps/basic/AGENTS.md +++ b/apps/basic/AGENTS.md @@ -37,7 +37,7 @@ Run from **this app root** (directory with `package.json` / `vite.config.ts`): **Purpose:** **schemas → `defineApi` modules → UI**; transport in `src/api/request.ts` only. -**Do:** Define APIs with `defineApi` in `src/api/.ts` (`method` default `GET`; `params`/`query`/`body` + `output`). Zod in `src/api/schemas.ts`. MSW envelopes `{ code, data, message }` aligned with schemas. Co-locate query keys (e.g. `userQueryKeys`). `hydrateSession` from `@/api/auth` after login/register/session restore. +**Do:** Define APIs with `defineApi` in `src/api//index.ts` (`method` default `GET`; `params`/`query`/`body` + `output`). Zod in `src/api//schemas.ts`; shared helpers in `src/api/shared/`. MSW envelopes `{ code, data, message }` aligned with schemas. Co-locate query keys (e.g. `userQueryKeys`). `hydrateSession` from `@/api/auth` after login/register/session restore. **Do not:** Call axios or `@/api/request` from routes/components — use resource functions (`listUsers`, …). Ad-hoc response shapes; skip schema parsing; mix unrelated domains in one module. diff --git a/apps/basic/README.md b/apps/basic/README.md index 6bb3e6b..7f3864f 100644 --- a/apps/basic/README.md +++ b/apps/basic/README.md @@ -106,7 +106,7 @@ pnpm run test:e2e:ui # interactive UI mode AGENTS.md # Agent rules (frontend/api/testing/refactor) .agents/skills/add-resource/ # CRUD scaffold checklist (Skill) src/ -├── api/ # schemas, request (axios + defineApi), resource APIs +├── api/ # request.ts, shared/, auth/, users/, … (defineApi per module) ├── components/ # Reusable UI primitives and layout shells │ ├── Layout/ # MainLayout, Header, Sidebar, UserMenu, AppFooter │ ├── DataTable/ # Shared table frame/skeleton/empty state diff --git a/apps/basic/src/api/auth.ts b/apps/basic/src/api/auth/index.ts similarity index 83% rename from apps/basic/src/api/auth.ts rename to apps/basic/src/api/auth/index.ts index d887cff..435bd78 100644 --- a/apps/basic/src/api/auth.ts +++ b/apps/basic/src/api/auth/index.ts @@ -6,26 +6,19 @@ import { LoginRequestSchema, PermissionsListSchema, RegisterRequestSchema, - UserSchema, -} from "@/api/schemas"; +} from "@/api/auth/schemas"; import type { AuthTokens, LoginRequest, - User, PermissionsList, RegisterRequest, -} from "@/api/schemas"; +} from "@/api/auth/schemas"; +import { UserSchema, type User } from "@/api/users/schemas"; +import { AUTH_ENDPOINTS } from "@/api/auth/paths"; import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu"; import { useAuthStore } from "@/stores/auth"; -export const AUTH_ENDPOINTS = { - login: "/api/auth/login", - register: "/api/auth/register", - refresh: "/api/auth/refresh", - logout: "/api/auth/logout", - user: "/api/auth/user", - permissions: "/api/auth/permissions", -} as const; +export { AUTH_ENDPOINTS } from "@/api/auth/paths"; export const login = defineApi({ method: "POST", diff --git a/apps/basic/src/api/auth/paths.ts b/apps/basic/src/api/auth/paths.ts new file mode 100644 index 0000000..4891120 --- /dev/null +++ b/apps/basic/src/api/auth/paths.ts @@ -0,0 +1,12 @@ +import { API_PREFIX } from "@/api/shared/prefix"; + +const AUTH_BASE = `${API_PREFIX}/auth`; + +export const AUTH_ENDPOINTS = { + login: `${AUTH_BASE}/login`, + register: `${AUTH_BASE}/register`, + refresh: `${AUTH_BASE}/refresh`, + logout: `${AUTH_BASE}/logout`, + user: `${AUTH_BASE}/user`, + permissions: `${AUTH_BASE}/permissions`, +} as const; diff --git a/apps/basic/src/api/auth/schemas.ts b/apps/basic/src/api/auth/schemas.ts new file mode 100644 index 0000000..9b22c4e --- /dev/null +++ b/apps/basic/src/api/auth/schemas.ts @@ -0,0 +1,48 @@ +import { z } from "zod/v4"; +import { UserSchema } from "@/api/users/schemas"; + +/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */ +export const AuthUserResponseSchema = UserSchema.omit({ permissions: true }); +export type AuthUserResponse = z.infer; + +export const AuthTokensSchema = z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().min(1), +}); + +export type AuthTokens = z.infer; + +export const RefreshTokenRequestSchema = z.object({ + refreshToken: z.string().min(1), +}); + +export type RefreshTokenRequest = z.infer; + +export const LoginRequestSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); + +export type LoginRequest = z.infer; + +/** Ant Design Form may submit `""` for an empty optional email */ +const registerOptionalEmailSchema = z + .union([z.string(), z.undefined()]) + .transform((v) => { + if (v === undefined) return undefined; + const t = String(v).trim(); + return t === "" ? undefined : t; + }) + .pipe(z.union([z.string().email(), z.undefined()])); + +export const RegisterRequestSchema = z.object({ + username: z.string().min(1), + password: z.string().min(6), + email: registerOptionalEmailSchema, +}); + +export type RegisterRequest = z.infer; + +export const PermissionsListSchema = z.array(z.string()); + +export type PermissionsList = z.infer; diff --git a/apps/basic/src/api/request.ts b/apps/basic/src/api/request.ts index 692a940..0a9ab36 100644 --- a/apps/basic/src/api/request.ts +++ b/apps/basic/src/api/request.ts @@ -1,11 +1,13 @@ import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios"; import { z } from "zod/v4"; import { API_BASE_URL } from "@/utils/constants"; -import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas"; +import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/auth/schemas"; import { getAccessToken, useAuthStore } from "@/stores/auth"; -/** Avoid importing `@/api/auth` here (circular with `defineApi` consumers). */ -const REFRESH_PATH = "/api/auth/refresh"; +import { AUTH_ENDPOINTS } from "@/api/auth/paths"; + +/** Avoid importing `@/api/auth` index here (circular with `defineApi` consumers). */ +const REFRESH_PATH = AUTH_ENDPOINTS.refresh; export class ApiError extends Error { code: number; diff --git a/apps/basic/src/api/schemas.ts b/apps/basic/src/api/schemas.ts index b2245d5..9544b0a 100644 --- a/apps/basic/src/api/schemas.ts +++ b/apps/basic/src/api/schemas.ts @@ -1,180 +1,4 @@ -import { z } from "zod/v4"; - -export const UserSchema = z.object({ - id: z.string(), - username: z.string(), - avatar: z.string().nullable(), - email: z.string().nullable(), - roles: z.array(z.string()), - permissions: z.array(z.string()), -}); - -export type User = z.infer; - -/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */ -export const AuthUserResponseSchema = UserSchema.omit({ permissions: true }); -export type AuthUserResponse = z.infer; - -export const AuthTokensSchema = z.object({ - accessToken: z.string().min(1), - refreshToken: z.string().min(1), -}); - -export type AuthTokens = z.infer; - -export const RefreshTokenRequestSchema = z.object({ - refreshToken: z.string().min(1), -}); - -export type RefreshTokenRequest = z.infer; - -export const LoginRequestSchema = z.object({ - username: z.string().min(1), - password: z.string().min(1), -}); - -export type LoginRequest = z.infer; - -/** Ant Design Form may submit `""` for an empty optional email */ -const registerOptionalEmailSchema = z - .union([z.string(), z.undefined()]) - .transform((v) => { - if (v === undefined) return undefined; - const t = String(v).trim(); - return t === "" ? undefined : t; - }) - .pipe(z.union([z.string().email(), z.undefined()])); - -export const RegisterRequestSchema = z.object({ - username: z.string().min(1), - password: z.string().min(6), - email: registerOptionalEmailSchema, -}); - -export type RegisterRequest = z.infer; - -export const PermissionsListSchema = z.array(z.string()); - -export type PermissionsList = z.infer; - -const BaseMenuNodeSchema = z.object({ - id: z.string(), - name: z.string(), - icon: z.string().nullable(), - permissions: z.array(z.string()).nullable(), - sort: z.int(), - hidden: z.boolean(), -}); - -export type MenuItem = - | (z.infer & { - kind: "item"; - path: string; - children: MenuItem[] | null; - }) - | (z.infer & { - kind: "group"; - path: null; - children: MenuItem[]; - }); - -export const MenuItemSchema: z.ZodType = z.lazy(() => - z.discriminatedUnion("kind", [ - BaseMenuNodeSchema.extend({ - kind: z.literal("item"), - path: z.string(), - children: z.array(MenuItemSchema).nullable(), - }), - BaseMenuNodeSchema.extend({ - kind: z.literal("group"), - path: z.null(), - children: z.array(MenuItemSchema), - }), - ]), -); - -export function ApiResponseSchema(dataSchema: T) { - return z.object({ - code: z.int(), - data: dataSchema, - message: z.string(), - }); -} - -export type ApiResponse = { - code: number; - data: T; - message: string; -}; - -export function PaginatedResponseSchema(itemSchema: T) { - return z.object({ - code: z.int(), - data: z.object({ - list: z.array(itemSchema), - total: z.int(), - }), - message: z.string(), - }); -} - -export type PaginatedData = { - list: T[]; - total: number; -}; - -/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */ -export function PaginatedDataSchema(itemSchema: T) { - return z.object({ - list: z.array(itemSchema), - total: z.int(), - }); -} - -export const ListUsersQuerySchema = z.object({ - limit: z.number().int().positive().optional(), - offset: z.number().int().nonnegative().optional(), - keyword: z.string().optional(), - role: z.string().optional(), - sortField: z.string().optional(), - sortOrder: z.enum(["ascend", "descend"]).optional(), -}); - -export type ListUsersQuery = z.infer; - -export const IdParamsSchema = z.object({ - id: z.string().min(1), -}); - -export type IdParams = z.infer; - -export const SearchParamsSchema = z.object({ - page: z.number().int().positive().catch(1), - pageSize: z.number().int().positive().catch(10), - sortField: z.string().nullable().catch(null), - sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null), -}); - -export type SearchParams = z.infer; - -/** Ant Design Input submits `""` when empty; treat as null for optional email */ -const createUserEmailSchema = z - .union([z.string(), z.null(), z.undefined()]) - .transform((v) => { - if (v == null) return null; - const t = String(v).trim(); - return t === "" ? null : t; - }) - .pipe(z.union([z.string().email(), z.null()])); - -export const CreateUserRequestSchema = z.object({ - username: z.string().min(1), - email: createUserEmailSchema, - roles: z.array(z.string()).min(1), -}); - -export type CreateUserRequest = z.infer; - -export const UpdateUserRequestSchema = CreateUserRequestSchema.partial(); - -export type UpdateUserRequest = z.infer; +/** @deprecated Import from `@/api//schemas` or `@/api/shared`; re-exports for compatibility. */ +export * from "@/api/shared"; +export * from "@/api/users/schemas"; +export * from "@/api/auth/schemas"; diff --git a/apps/basic/src/api/shared/envelope.ts b/apps/basic/src/api/shared/envelope.ts new file mode 100644 index 0000000..7d855e7 --- /dev/null +++ b/apps/basic/src/api/shared/envelope.ts @@ -0,0 +1,26 @@ +import { z } from "zod/v4"; + +export function ApiResponseSchema(dataSchema: T) { + return z.object({ + code: z.int(), + data: dataSchema, + message: z.string(), + }); +} + +export type ApiResponse = { + code: number; + data: T; + message: string; +}; + +export function PaginatedResponseSchema(itemSchema: T) { + return z.object({ + code: z.int(), + data: z.object({ + list: z.array(itemSchema), + total: z.int(), + }), + message: z.string(), + }); +} diff --git a/apps/basic/src/api/shared/index.ts b/apps/basic/src/api/shared/index.ts new file mode 100644 index 0000000..3a98f45 --- /dev/null +++ b/apps/basic/src/api/shared/index.ts @@ -0,0 +1,5 @@ +export * from "./envelope"; +export * from "./menu"; +export * from "./pagination"; +export * from "./params"; +export * from "./prefix"; diff --git a/apps/basic/src/api/shared/menu.ts b/apps/basic/src/api/shared/menu.ts new file mode 100644 index 0000000..2488dfe --- /dev/null +++ b/apps/basic/src/api/shared/menu.ts @@ -0,0 +1,37 @@ +import { z } from "zod/v4"; + +const BaseMenuNodeSchema = z.object({ + id: z.string(), + name: z.string(), + icon: z.string().nullable(), + permissions: z.array(z.string()).nullable(), + sort: z.int(), + hidden: z.boolean(), +}); + +export type MenuItem = + | (z.infer & { + kind: "item"; + path: string; + children: MenuItem[] | null; + }) + | (z.infer & { + kind: "group"; + path: null; + children: MenuItem[]; + }); + +export const MenuItemSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion("kind", [ + BaseMenuNodeSchema.extend({ + kind: z.literal("item"), + path: z.string(), + children: z.array(MenuItemSchema).nullable(), + }), + BaseMenuNodeSchema.extend({ + kind: z.literal("group"), + path: z.null(), + children: z.array(MenuItemSchema), + }), + ]), +); diff --git a/apps/basic/src/api/shared/pagination.ts b/apps/basic/src/api/shared/pagination.ts new file mode 100644 index 0000000..37a388b --- /dev/null +++ b/apps/basic/src/api/shared/pagination.ts @@ -0,0 +1,14 @@ +import { z } from "zod/v4"; + +export type PaginatedData = { + list: T[]; + total: number; +}; + +/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */ +export function PaginatedDataSchema(itemSchema: T) { + return z.object({ + list: z.array(itemSchema), + total: z.int(), + }); +} diff --git a/apps/basic/src/api/shared/params.ts b/apps/basic/src/api/shared/params.ts new file mode 100644 index 0000000..9cee2d6 --- /dev/null +++ b/apps/basic/src/api/shared/params.ts @@ -0,0 +1,16 @@ +import { z } from "zod/v4"; + +export const IdParamsSchema = z.object({ + id: z.string().min(1), +}); + +export type IdParams = z.infer; + +export const SearchParamsSchema = z.object({ + page: z.number().int().positive().catch(1), + pageSize: z.number().int().positive().catch(10), + sortField: z.string().nullable().catch(null), + sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null), +}); + +export type SearchParams = z.infer; diff --git a/apps/basic/src/api/shared/prefix.ts b/apps/basic/src/api/shared/prefix.ts new file mode 100644 index 0000000..b463808 --- /dev/null +++ b/apps/basic/src/api/shared/prefix.ts @@ -0,0 +1,2 @@ +/** REST API path prefix (no trailing slash). */ +export const API_PREFIX = "/api" as const; diff --git a/apps/basic/src/api/user.ts b/apps/basic/src/api/users/index.ts similarity index 63% rename from apps/basic/src/api/user.ts rename to apps/basic/src/api/users/index.ts index d71f2d8..3bae8b0 100644 --- a/apps/basic/src/api/user.ts +++ b/apps/basic/src/api/users/index.ts @@ -1,21 +1,23 @@ import { z } from "zod/v4"; import { defineApi } from "@/api/request"; +import { IdParamsSchema } from "@/api/shared/params"; +import { PaginatedDataSchema } from "@/api/shared/pagination"; import { CreateUserRequestSchema, - IdParamsSchema, ListUsersQuerySchema, - PaginatedDataSchema, UpdateUserRequestSchema, UserSchema, -} from "@/api/schemas"; -import type { CreateUserRequest, ListUsersQuery, UpdateUserRequest, User } from "@/api/schemas"; +} from "@/api/users/schemas"; +import type { + CreateUserRequest, + ListUsersQuery, + UpdateUserRequest, + User, +} from "@/api/users/schemas"; +import { API_PREFIX } from "@/api/shared/prefix"; -export const USER_ENDPOINTS = { - list: "/api/users", - create: "/api/users", - update: (id: string) => `/api/users/${id}`, - delete: (id: string) => `/api/users/${id}`, -} as const; +export const USERS_PATH = `${API_PREFIX}/users`; +export const USERS_MEMBER_PATH = `${USERS_PATH}/:id`; export const userQueryKeys = { all: ["users"] as const, @@ -23,21 +25,21 @@ export const userQueryKeys = { }; export const listUsers = defineApi({ - path: USER_ENDPOINTS.list, + path: USERS_PATH, query: ListUsersQuerySchema, output: PaginatedDataSchema(UserSchema), }); export const createUser = defineApi({ method: "POST", - path: USER_ENDPOINTS.list, + path: USERS_PATH, body: CreateUserRequestSchema, output: UserSchema, }); export const updateUser = defineApi({ method: "PUT", - path: "/api/users/:id", + path: USERS_MEMBER_PATH, params: IdParamsSchema, body: UpdateUserRequestSchema, output: UserSchema, @@ -45,7 +47,7 @@ export const updateUser = defineApi({ export const deleteUser = defineApi({ method: "DELETE", - path: "/api/users/:id", + path: USERS_MEMBER_PATH, params: IdParamsSchema, output: z.null(), }); diff --git a/apps/basic/src/api/users/schemas.ts b/apps/basic/src/api/users/schemas.ts new file mode 100644 index 0000000..51562ba --- /dev/null +++ b/apps/basic/src/api/users/schemas.ts @@ -0,0 +1,45 @@ +import { z } from "zod/v4"; + +export const UserSchema = z.object({ + id: z.string(), + username: z.string(), + avatar: z.string().nullable(), + email: z.string().nullable(), + roles: z.array(z.string()), + permissions: z.array(z.string()), +}); + +export type User = z.infer; + +export const ListUsersQuerySchema = z.object({ + limit: z.number().int().positive().optional(), + offset: z.number().int().nonnegative().optional(), + keyword: z.string().optional(), + role: z.string().optional(), + sortField: z.string().optional(), + sortOrder: z.enum(["ascend", "descend"]).optional(), +}); + +export type ListUsersQuery = z.infer; + +/** Ant Design Input submits `""` when empty; treat as null for optional email */ +const createUserEmailSchema = z + .union([z.string(), z.null(), z.undefined()]) + .transform((v) => { + if (v == null) return null; + const t = String(v).trim(); + return t === "" ? null : t; + }) + .pipe(z.union([z.string().email(), z.null()])); + +export const CreateUserRequestSchema = z.object({ + username: z.string().min(1), + email: createUserEmailSchema, + roles: z.array(z.string()).min(1), +}); + +export type CreateUserRequest = z.infer; + +export const UpdateUserRequestSchema = CreateUserRequestSchema.partial(); + +export type UpdateUserRequest = z.infer; diff --git a/apps/basic/src/mocks/handlers/auth.ts b/apps/basic/src/mocks/handlers/auth.ts index fe197d5..0ee71d4 100644 --- a/apps/basic/src/mocks/handlers/auth.ts +++ b/apps/basic/src/mocks/handlers/auth.ts @@ -15,6 +15,7 @@ import { RefreshTokenRequestSchema, RegisterRequestSchema, } from "@/api/schemas"; +import { AUTH_ENDPOINTS } from "@/api/auth/paths"; const GUEST_REFRESH = "mock-guest-refresh"; const REGISTERED_ACCESS = "mock-registered-access"; @@ -32,7 +33,7 @@ function isRegisteredAuth(authorization: string | null): boolean { } export const authHandlers = [ - http.post("/api/auth/login", async ({ request }) => { + http.post(AUTH_ENDPOINTS.login, async ({ request }) => { await withDelay(300); let json: unknown; try { @@ -60,7 +61,7 @@ export const authHandlers = [ return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password"); }), - http.post("/api/auth/register", async ({ request }) => { + http.post(AUTH_ENDPOINTS.register, async ({ request }) => { await withDelay(300); let json: unknown; try { @@ -90,7 +91,7 @@ export const authHandlers = [ }); }), - http.post("/api/auth/refresh", async ({ request }) => { + http.post(AUTH_ENDPOINTS.refresh, async ({ request }) => { await withDelay(100); let json: unknown; try { @@ -123,9 +124,9 @@ export const authHandlers = [ }); }), - http.post("/api/auth/logout", () => successWithNullBody()), + http.post(AUTH_ENDPOINTS.logout, () => successWithNullBody()), - http.get("/api/auth/user", ({ request }) => { + http.get(AUTH_ENDPOINTS.user, ({ request }) => { const auth = request.headers.get("authorization"); if (isRegisteredAuth(auth)) { const session = mockRegisteredSession; @@ -146,7 +147,7 @@ export const authHandlers = [ return successWithSchema(AuthUserResponseSchema, userWithoutPermissions); }), - http.get("/api/auth/permissions", ({ request }) => { + http.get(AUTH_ENDPOINTS.permissions, ({ request }) => { const auth = request.headers.get("authorization"); if (isRegisteredAuth(auth) && mockRegisteredSession) { return successWithSchema(PermissionsListSchema, ["user:view"] as string[]); diff --git a/apps/basic/src/mocks/handlers/user.ts b/apps/basic/src/mocks/handlers/user.ts index bc81598..014783c 100644 --- a/apps/basic/src/mocks/handlers/user.ts +++ b/apps/basic/src/mocks/handlers/user.ts @@ -10,13 +10,14 @@ import { successWithSchema, successWithNullBody, } from "../createHandler"; +import { USERS_MEMBER_PATH, USERS_PATH } from "@/api/users"; let users = [...MOCK_USERS]; export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__"; export const userHandlers = [ - http.get("/api/users", async ({ request }) => { + http.get(USERS_PATH, async ({ request }) => { await withDelay(200); const auth = request.headers.get("authorization"); if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) { @@ -33,7 +34,7 @@ export const userHandlers = [ return paginatedWithSchema(UserSchema, { list, total: filtered.length }); }), - http.post("/api/users", async ({ request }) => { + http.post(USERS_PATH, async ({ request }) => { await withDelay(200); const body = (await request.json()) as Record; const newUser = { @@ -48,7 +49,7 @@ export const userHandlers = [ return successWithSchema(UserSchema, newUser); }), - http.put("/api/users/:id", async ({ params, request }) => { + http.put(USERS_MEMBER_PATH, async ({ params, request }) => { await withDelay(200); const body = (await request.json()) as Record; const idx = users.findIndex((u) => u.id === params.id); @@ -59,7 +60,7 @@ export const userHandlers = [ return successWithSchema(UserSchema, users[idx]); }), - http.delete("/api/users/:id", async ({ params }) => { + http.delete(USERS_MEMBER_PATH, async ({ params }) => { await withDelay(200); users = users.filter((u) => u.id !== params.id); return successWithNullBody(); diff --git a/apps/basic/src/routes/_auth/users/index.tsx b/apps/basic/src/routes/_auth/users/index.tsx index d4ad497..a273e4e 100644 --- a/apps/basic/src/routes/_auth/users/index.tsx +++ b/apps/basic/src/routes/_auth/users/index.tsx @@ -2,7 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd"; import type { TablePaginationConfig } from "antd/es/table/interface"; import { useMemo, useRef, useState } from "react"; -import { createUser, deleteUser, listUsers, updateUser, userQueryKeys } from "@/api/user"; +import { createUser, deleteUser, listUsers, updateUser, userQueryKeys } from "@/api/users"; import type { User, CreateUserRequest } from "@/api/schemas"; import { z } from "zod/v4"; import { MoreVertical, Pencil, Trash2 } from "lucide-react"; diff --git a/apps/with-lingui/.agents/skills/add-resource/SKILL.md b/apps/with-lingui/.agents/skills/add-resource/SKILL.md index f5f6f7a..5b034de 100644 --- a/apps/with-lingui/.agents/skills/add-resource/SKILL.md +++ b/apps/with-lingui/.agents/skills/add-resource/SKILL.md @@ -17,14 +17,14 @@ pnpm exec vp check --no-fmt pnpm run test:e2e -- e2e/.spec.ts ``` -## 1. Zod and types (`src/api/schemas.ts`) +## 1. Zod and types (`src/api//schemas.ts`) - Define `Schema`, `CreateRequestSchema`, `UpdateRequestSchema` (`Update` = `Create.partial()`). - For list `output`, reuse `PaginatedDataSchema(Schema)` (unwrapped `data`; envelope stays in `request.ts`). - Add list query schema (e.g. `ListQuerySchema`) when the table has filters/pagination. - Export `type = z.infer`. -## 2. Resource API module (`src/api/.ts`) +## 2. Resource API module (`src/api//index.ts`) - Export path constants if useful for MSW/docs. - Define APIs with `defineApi` from `@/api/request`: `list` / `create` / `update` / `delete` (and `queryKeys`). diff --git a/apps/with-lingui/AGENTS.md b/apps/with-lingui/AGENTS.md index 5712134..19557ac 100644 --- a/apps/with-lingui/AGENTS.md +++ b/apps/with-lingui/AGENTS.md @@ -40,7 +40,7 @@ Run from **this app root** (directory with `package.json` / `vite.config.ts`): **Purpose:** **schemas → `defineApi` modules → UI**; transport in `src/api/request.ts` only. -**Do:** Define APIs with `defineApi` in `src/api/.ts` (`method` default `GET`; `params`/`query`/`body` + `output`). Zod in `src/api/schemas.ts`. MSW envelopes `{ code, data, message }` aligned with schemas. Co-locate query keys (e.g. `userQueryKeys`). `hydrateSession` from `@/api/auth` after login/register/session restore. +**Do:** Define APIs with `defineApi` in `src/api//index.ts` (`method` default `GET`; `params`/`query`/`body` + `output`). Zod in `src/api//schemas.ts`; shared helpers in `src/api/shared/`. MSW envelopes `{ code, data, message }` aligned with schemas. Co-locate query keys (e.g. `userQueryKeys`). `hydrateSession` from `@/api/auth` after login/register/session restore. **Do not:** Call axios or `@/api/request` from routes/components — use resource functions (`listUsers`, …). Ad-hoc response shapes; skip schema parsing; mix unrelated domains in one module. diff --git a/apps/with-lingui/README.md b/apps/with-lingui/README.md index 4bb4b96..3405223 100644 --- a/apps/with-lingui/README.md +++ b/apps/with-lingui/README.md @@ -109,7 +109,7 @@ pnpm run i18n:extract && git diff --exit-code -- src/locales AGENTS.md # Agent rules (frontend/api/testing/refactor) .agents/skills/add-resource/ # CRUD scaffold checklist (Skill) src/ -├── api/ # schemas, request (axios + defineApi), resource APIs +├── api/ # request.ts, shared/, auth/, users/, … (defineApi per module) ├── components/ # Reusable UI primitives and layout shells │ ├── Layout/ # MainLayout, Header, Sidebar, UserMenu, AppFooter │ ├── DataTable/ # Shared table frame/skeleton/empty state diff --git a/apps/with-lingui/src/api/auth.ts b/apps/with-lingui/src/api/auth/index.ts similarity index 83% rename from apps/with-lingui/src/api/auth.ts rename to apps/with-lingui/src/api/auth/index.ts index d887cff..435bd78 100644 --- a/apps/with-lingui/src/api/auth.ts +++ b/apps/with-lingui/src/api/auth/index.ts @@ -6,26 +6,19 @@ import { LoginRequestSchema, PermissionsListSchema, RegisterRequestSchema, - UserSchema, -} from "@/api/schemas"; +} from "@/api/auth/schemas"; import type { AuthTokens, LoginRequest, - User, PermissionsList, RegisterRequest, -} from "@/api/schemas"; +} from "@/api/auth/schemas"; +import { UserSchema, type User } from "@/api/users/schemas"; +import { AUTH_ENDPOINTS } from "@/api/auth/paths"; import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu"; import { useAuthStore } from "@/stores/auth"; -export const AUTH_ENDPOINTS = { - login: "/api/auth/login", - register: "/api/auth/register", - refresh: "/api/auth/refresh", - logout: "/api/auth/logout", - user: "/api/auth/user", - permissions: "/api/auth/permissions", -} as const; +export { AUTH_ENDPOINTS } from "@/api/auth/paths"; export const login = defineApi({ method: "POST", diff --git a/apps/with-lingui/src/api/auth/paths.ts b/apps/with-lingui/src/api/auth/paths.ts new file mode 100644 index 0000000..4891120 --- /dev/null +++ b/apps/with-lingui/src/api/auth/paths.ts @@ -0,0 +1,12 @@ +import { API_PREFIX } from "@/api/shared/prefix"; + +const AUTH_BASE = `${API_PREFIX}/auth`; + +export const AUTH_ENDPOINTS = { + login: `${AUTH_BASE}/login`, + register: `${AUTH_BASE}/register`, + refresh: `${AUTH_BASE}/refresh`, + logout: `${AUTH_BASE}/logout`, + user: `${AUTH_BASE}/user`, + permissions: `${AUTH_BASE}/permissions`, +} as const; diff --git a/apps/with-lingui/src/api/auth/schemas.ts b/apps/with-lingui/src/api/auth/schemas.ts new file mode 100644 index 0000000..9b22c4e --- /dev/null +++ b/apps/with-lingui/src/api/auth/schemas.ts @@ -0,0 +1,48 @@ +import { z } from "zod/v4"; +import { UserSchema } from "@/api/users/schemas"; + +/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */ +export const AuthUserResponseSchema = UserSchema.omit({ permissions: true }); +export type AuthUserResponse = z.infer; + +export const AuthTokensSchema = z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().min(1), +}); + +export type AuthTokens = z.infer; + +export const RefreshTokenRequestSchema = z.object({ + refreshToken: z.string().min(1), +}); + +export type RefreshTokenRequest = z.infer; + +export const LoginRequestSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); + +export type LoginRequest = z.infer; + +/** Ant Design Form may submit `""` for an empty optional email */ +const registerOptionalEmailSchema = z + .union([z.string(), z.undefined()]) + .transform((v) => { + if (v === undefined) return undefined; + const t = String(v).trim(); + return t === "" ? undefined : t; + }) + .pipe(z.union([z.string().email(), z.undefined()])); + +export const RegisterRequestSchema = z.object({ + username: z.string().min(1), + password: z.string().min(6), + email: registerOptionalEmailSchema, +}); + +export type RegisterRequest = z.infer; + +export const PermissionsListSchema = z.array(z.string()); + +export type PermissionsList = z.infer; diff --git a/apps/with-lingui/src/api/request.ts b/apps/with-lingui/src/api/request.ts index 692a940..0a9ab36 100644 --- a/apps/with-lingui/src/api/request.ts +++ b/apps/with-lingui/src/api/request.ts @@ -1,11 +1,13 @@ import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios"; import { z } from "zod/v4"; import { API_BASE_URL } from "@/utils/constants"; -import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/schemas"; +import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/auth/schemas"; import { getAccessToken, useAuthStore } from "@/stores/auth"; -/** Avoid importing `@/api/auth` here (circular with `defineApi` consumers). */ -const REFRESH_PATH = "/api/auth/refresh"; +import { AUTH_ENDPOINTS } from "@/api/auth/paths"; + +/** Avoid importing `@/api/auth` index here (circular with `defineApi` consumers). */ +const REFRESH_PATH = AUTH_ENDPOINTS.refresh; export class ApiError extends Error { code: number; diff --git a/apps/with-lingui/src/api/schemas.ts b/apps/with-lingui/src/api/schemas.ts index f21f607..9544b0a 100644 --- a/apps/with-lingui/src/api/schemas.ts +++ b/apps/with-lingui/src/api/schemas.ts @@ -1,179 +1,4 @@ -import { z } from "zod/v4"; - -export const UserSchema = z.object({ - id: z.string(), - username: z.string(), - avatar: z.string().nullable(), - email: z.string().nullable(), - roles: z.array(z.string()), - permissions: z.array(z.string()), -}); - -export type User = z.infer; - -export const AuthUserResponseSchema = UserSchema.omit({ permissions: true }); -export type AuthUserResponse = z.infer; - -export const AuthTokensSchema = z.object({ - accessToken: z.string().min(1), - refreshToken: z.string().min(1), -}); - -export type AuthTokens = z.infer; - -export const RefreshTokenRequestSchema = z.object({ - refreshToken: z.string().min(1), -}); - -export type RefreshTokenRequest = z.infer; - -export const LoginRequestSchema = z.object({ - username: z.string().min(1), - password: z.string().min(1), -}); - -export type LoginRequest = z.infer; - -/** Ant Design Form may submit `""` for an empty optional email */ -const registerOptionalEmailSchema = z - .union([z.string(), z.undefined()]) - .transform((v) => { - if (v === undefined) return undefined; - const t = String(v).trim(); - return t === "" ? undefined : t; - }) - .pipe(z.union([z.string().email(), z.undefined()])); - -export const RegisterRequestSchema = z.object({ - username: z.string().min(1), - password: z.string().min(6), - email: registerOptionalEmailSchema, -}); - -export type RegisterRequest = z.infer; - -export const PermissionsListSchema = z.array(z.string()); - -export type PermissionsList = z.infer; - -const BaseMenuNodeSchema = z.object({ - id: z.string(), - name: z.string(), - icon: z.string().nullable(), - permissions: z.array(z.string()).nullable(), - sort: z.int(), - hidden: z.boolean(), -}); - -export type MenuItem = - | (z.infer & { - kind: "item"; - path: string; - children: MenuItem[] | null; - }) - | (z.infer & { - kind: "group"; - path: null; - children: MenuItem[]; - }); - -export const MenuItemSchema: z.ZodType = z.lazy(() => - z.discriminatedUnion("kind", [ - BaseMenuNodeSchema.extend({ - kind: z.literal("item"), - path: z.string(), - children: z.array(MenuItemSchema).nullable(), - }), - BaseMenuNodeSchema.extend({ - kind: z.literal("group"), - path: z.null(), - children: z.array(MenuItemSchema), - }), - ]), -); - -export function ApiResponseSchema(dataSchema: T) { - return z.object({ - code: z.int(), - data: dataSchema, - message: z.string(), - }); -} - -export type ApiResponse = { - code: number; - data: T; - message: string; -}; - -export function PaginatedResponseSchema(itemSchema: T) { - return z.object({ - code: z.int(), - data: z.object({ - list: z.array(itemSchema), - total: z.int(), - }), - message: z.string(), - }); -} - -export type PaginatedData = { - list: T[]; - total: number; -}; - -/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */ -export function PaginatedDataSchema(itemSchema: T) { - return z.object({ - list: z.array(itemSchema), - total: z.int(), - }); -} - -export const ListUsersQuerySchema = z.object({ - limit: z.number().int().positive().optional(), - offset: z.number().int().nonnegative().optional(), - keyword: z.string().optional(), - role: z.string().optional(), - sortField: z.string().optional(), - sortOrder: z.enum(["ascend", "descend"]).optional(), -}); - -export type ListUsersQuery = z.infer; - -export const IdParamsSchema = z.object({ - id: z.string().min(1), -}); - -export type IdParams = z.infer; - -export const SearchParamsSchema = z.object({ - page: z.number().int().positive().catch(1), - pageSize: z.number().int().positive().catch(10), - sortField: z.string().nullable().catch(null), - sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null), -}); - -export type SearchParams = z.infer; - -/** Ant Design Input submits `""` when empty; treat as null for optional email */ -const createUserEmailSchema = z - .union([z.string(), z.null(), z.undefined()]) - .transform((v) => { - if (v == null) return null; - const t = String(v).trim(); - return t === "" ? null : t; - }) - .pipe(z.union([z.string().email(), z.null()])); - -export const CreateUserRequestSchema = z.object({ - username: z.string().min(1), - email: createUserEmailSchema, - roles: z.array(z.string()).min(1), -}); - -export type CreateUserRequest = z.infer; - -export const UpdateUserRequestSchema = CreateUserRequestSchema.partial(); - -export type UpdateUserRequest = z.infer; +/** @deprecated Import from `@/api//schemas` or `@/api/shared`; re-exports for compatibility. */ +export * from "@/api/shared"; +export * from "@/api/users/schemas"; +export * from "@/api/auth/schemas"; diff --git a/apps/with-lingui/src/api/shared/envelope.ts b/apps/with-lingui/src/api/shared/envelope.ts new file mode 100644 index 0000000..7d855e7 --- /dev/null +++ b/apps/with-lingui/src/api/shared/envelope.ts @@ -0,0 +1,26 @@ +import { z } from "zod/v4"; + +export function ApiResponseSchema(dataSchema: T) { + return z.object({ + code: z.int(), + data: dataSchema, + message: z.string(), + }); +} + +export type ApiResponse = { + code: number; + data: T; + message: string; +}; + +export function PaginatedResponseSchema(itemSchema: T) { + return z.object({ + code: z.int(), + data: z.object({ + list: z.array(itemSchema), + total: z.int(), + }), + message: z.string(), + }); +} diff --git a/apps/with-lingui/src/api/shared/index.ts b/apps/with-lingui/src/api/shared/index.ts new file mode 100644 index 0000000..3a98f45 --- /dev/null +++ b/apps/with-lingui/src/api/shared/index.ts @@ -0,0 +1,5 @@ +export * from "./envelope"; +export * from "./menu"; +export * from "./pagination"; +export * from "./params"; +export * from "./prefix"; diff --git a/apps/with-lingui/src/api/shared/menu.ts b/apps/with-lingui/src/api/shared/menu.ts new file mode 100644 index 0000000..2488dfe --- /dev/null +++ b/apps/with-lingui/src/api/shared/menu.ts @@ -0,0 +1,37 @@ +import { z } from "zod/v4"; + +const BaseMenuNodeSchema = z.object({ + id: z.string(), + name: z.string(), + icon: z.string().nullable(), + permissions: z.array(z.string()).nullable(), + sort: z.int(), + hidden: z.boolean(), +}); + +export type MenuItem = + | (z.infer & { + kind: "item"; + path: string; + children: MenuItem[] | null; + }) + | (z.infer & { + kind: "group"; + path: null; + children: MenuItem[]; + }); + +export const MenuItemSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion("kind", [ + BaseMenuNodeSchema.extend({ + kind: z.literal("item"), + path: z.string(), + children: z.array(MenuItemSchema).nullable(), + }), + BaseMenuNodeSchema.extend({ + kind: z.literal("group"), + path: z.null(), + children: z.array(MenuItemSchema), + }), + ]), +); diff --git a/apps/with-lingui/src/api/shared/pagination.ts b/apps/with-lingui/src/api/shared/pagination.ts new file mode 100644 index 0000000..37a388b --- /dev/null +++ b/apps/with-lingui/src/api/shared/pagination.ts @@ -0,0 +1,14 @@ +import { z } from "zod/v4"; + +export type PaginatedData = { + list: T[]; + total: number; +}; + +/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */ +export function PaginatedDataSchema(itemSchema: T) { + return z.object({ + list: z.array(itemSchema), + total: z.int(), + }); +} diff --git a/apps/with-lingui/src/api/shared/params.ts b/apps/with-lingui/src/api/shared/params.ts new file mode 100644 index 0000000..9cee2d6 --- /dev/null +++ b/apps/with-lingui/src/api/shared/params.ts @@ -0,0 +1,16 @@ +import { z } from "zod/v4"; + +export const IdParamsSchema = z.object({ + id: z.string().min(1), +}); + +export type IdParams = z.infer; + +export const SearchParamsSchema = z.object({ + page: z.number().int().positive().catch(1), + pageSize: z.number().int().positive().catch(10), + sortField: z.string().nullable().catch(null), + sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null), +}); + +export type SearchParams = z.infer; diff --git a/apps/with-lingui/src/api/shared/prefix.ts b/apps/with-lingui/src/api/shared/prefix.ts new file mode 100644 index 0000000..b463808 --- /dev/null +++ b/apps/with-lingui/src/api/shared/prefix.ts @@ -0,0 +1,2 @@ +/** REST API path prefix (no trailing slash). */ +export const API_PREFIX = "/api" as const; diff --git a/apps/with-lingui/src/api/user.ts b/apps/with-lingui/src/api/users/index.ts similarity index 63% rename from apps/with-lingui/src/api/user.ts rename to apps/with-lingui/src/api/users/index.ts index d71f2d8..3bae8b0 100644 --- a/apps/with-lingui/src/api/user.ts +++ b/apps/with-lingui/src/api/users/index.ts @@ -1,21 +1,23 @@ import { z } from "zod/v4"; import { defineApi } from "@/api/request"; +import { IdParamsSchema } from "@/api/shared/params"; +import { PaginatedDataSchema } from "@/api/shared/pagination"; import { CreateUserRequestSchema, - IdParamsSchema, ListUsersQuerySchema, - PaginatedDataSchema, UpdateUserRequestSchema, UserSchema, -} from "@/api/schemas"; -import type { CreateUserRequest, ListUsersQuery, UpdateUserRequest, User } from "@/api/schemas"; +} from "@/api/users/schemas"; +import type { + CreateUserRequest, + ListUsersQuery, + UpdateUserRequest, + User, +} from "@/api/users/schemas"; +import { API_PREFIX } from "@/api/shared/prefix"; -export const USER_ENDPOINTS = { - list: "/api/users", - create: "/api/users", - update: (id: string) => `/api/users/${id}`, - delete: (id: string) => `/api/users/${id}`, -} as const; +export const USERS_PATH = `${API_PREFIX}/users`; +export const USERS_MEMBER_PATH = `${USERS_PATH}/:id`; export const userQueryKeys = { all: ["users"] as const, @@ -23,21 +25,21 @@ export const userQueryKeys = { }; export const listUsers = defineApi({ - path: USER_ENDPOINTS.list, + path: USERS_PATH, query: ListUsersQuerySchema, output: PaginatedDataSchema(UserSchema), }); export const createUser = defineApi({ method: "POST", - path: USER_ENDPOINTS.list, + path: USERS_PATH, body: CreateUserRequestSchema, output: UserSchema, }); export const updateUser = defineApi({ method: "PUT", - path: "/api/users/:id", + path: USERS_MEMBER_PATH, params: IdParamsSchema, body: UpdateUserRequestSchema, output: UserSchema, @@ -45,7 +47,7 @@ export const updateUser = defineApi({ export const deleteUser = defineApi({ method: "DELETE", - path: "/api/users/:id", + path: USERS_MEMBER_PATH, params: IdParamsSchema, output: z.null(), }); diff --git a/apps/with-lingui/src/api/users/schemas.ts b/apps/with-lingui/src/api/users/schemas.ts new file mode 100644 index 0000000..51562ba --- /dev/null +++ b/apps/with-lingui/src/api/users/schemas.ts @@ -0,0 +1,45 @@ +import { z } from "zod/v4"; + +export const UserSchema = z.object({ + id: z.string(), + username: z.string(), + avatar: z.string().nullable(), + email: z.string().nullable(), + roles: z.array(z.string()), + permissions: z.array(z.string()), +}); + +export type User = z.infer; + +export const ListUsersQuerySchema = z.object({ + limit: z.number().int().positive().optional(), + offset: z.number().int().nonnegative().optional(), + keyword: z.string().optional(), + role: z.string().optional(), + sortField: z.string().optional(), + sortOrder: z.enum(["ascend", "descend"]).optional(), +}); + +export type ListUsersQuery = z.infer; + +/** Ant Design Input submits `""` when empty; treat as null for optional email */ +const createUserEmailSchema = z + .union([z.string(), z.null(), z.undefined()]) + .transform((v) => { + if (v == null) return null; + const t = String(v).trim(); + return t === "" ? null : t; + }) + .pipe(z.union([z.string().email(), z.null()])); + +export const CreateUserRequestSchema = z.object({ + username: z.string().min(1), + email: createUserEmailSchema, + roles: z.array(z.string()).min(1), +}); + +export type CreateUserRequest = z.infer; + +export const UpdateUserRequestSchema = CreateUserRequestSchema.partial(); + +export type UpdateUserRequest = z.infer; diff --git a/apps/with-lingui/src/mocks/handlers/auth.ts b/apps/with-lingui/src/mocks/handlers/auth.ts index fe197d5..0ee71d4 100644 --- a/apps/with-lingui/src/mocks/handlers/auth.ts +++ b/apps/with-lingui/src/mocks/handlers/auth.ts @@ -15,6 +15,7 @@ import { RefreshTokenRequestSchema, RegisterRequestSchema, } from "@/api/schemas"; +import { AUTH_ENDPOINTS } from "@/api/auth/paths"; const GUEST_REFRESH = "mock-guest-refresh"; const REGISTERED_ACCESS = "mock-registered-access"; @@ -32,7 +33,7 @@ function isRegisteredAuth(authorization: string | null): boolean { } export const authHandlers = [ - http.post("/api/auth/login", async ({ request }) => { + http.post(AUTH_ENDPOINTS.login, async ({ request }) => { await withDelay(300); let json: unknown; try { @@ -60,7 +61,7 @@ export const authHandlers = [ return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password"); }), - http.post("/api/auth/register", async ({ request }) => { + http.post(AUTH_ENDPOINTS.register, async ({ request }) => { await withDelay(300); let json: unknown; try { @@ -90,7 +91,7 @@ export const authHandlers = [ }); }), - http.post("/api/auth/refresh", async ({ request }) => { + http.post(AUTH_ENDPOINTS.refresh, async ({ request }) => { await withDelay(100); let json: unknown; try { @@ -123,9 +124,9 @@ export const authHandlers = [ }); }), - http.post("/api/auth/logout", () => successWithNullBody()), + http.post(AUTH_ENDPOINTS.logout, () => successWithNullBody()), - http.get("/api/auth/user", ({ request }) => { + http.get(AUTH_ENDPOINTS.user, ({ request }) => { const auth = request.headers.get("authorization"); if (isRegisteredAuth(auth)) { const session = mockRegisteredSession; @@ -146,7 +147,7 @@ export const authHandlers = [ return successWithSchema(AuthUserResponseSchema, userWithoutPermissions); }), - http.get("/api/auth/permissions", ({ request }) => { + http.get(AUTH_ENDPOINTS.permissions, ({ request }) => { const auth = request.headers.get("authorization"); if (isRegisteredAuth(auth) && mockRegisteredSession) { return successWithSchema(PermissionsListSchema, ["user:view"] as string[]); diff --git a/apps/with-lingui/src/mocks/handlers/user.ts b/apps/with-lingui/src/mocks/handlers/user.ts index bc81598..014783c 100644 --- a/apps/with-lingui/src/mocks/handlers/user.ts +++ b/apps/with-lingui/src/mocks/handlers/user.ts @@ -10,13 +10,14 @@ import { successWithSchema, successWithNullBody, } from "../createHandler"; +import { USERS_MEMBER_PATH, USERS_PATH } from "@/api/users"; let users = [...MOCK_USERS]; export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__"; export const userHandlers = [ - http.get("/api/users", async ({ request }) => { + http.get(USERS_PATH, async ({ request }) => { await withDelay(200); const auth = request.headers.get("authorization"); if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) { @@ -33,7 +34,7 @@ export const userHandlers = [ return paginatedWithSchema(UserSchema, { list, total: filtered.length }); }), - http.post("/api/users", async ({ request }) => { + http.post(USERS_PATH, async ({ request }) => { await withDelay(200); const body = (await request.json()) as Record; const newUser = { @@ -48,7 +49,7 @@ export const userHandlers = [ return successWithSchema(UserSchema, newUser); }), - http.put("/api/users/:id", async ({ params, request }) => { + http.put(USERS_MEMBER_PATH, async ({ params, request }) => { await withDelay(200); const body = (await request.json()) as Record; const idx = users.findIndex((u) => u.id === params.id); @@ -59,7 +60,7 @@ export const userHandlers = [ return successWithSchema(UserSchema, users[idx]); }), - http.delete("/api/users/:id", async ({ params }) => { + http.delete(USERS_MEMBER_PATH, async ({ params }) => { await withDelay(200); users = users.filter((u) => u.id !== params.id); return successWithNullBody(); diff --git a/apps/with-lingui/src/routes/_auth/users/index.tsx b/apps/with-lingui/src/routes/_auth/users/index.tsx index 3f061fa..ee8d491 100644 --- a/apps/with-lingui/src/routes/_auth/users/index.tsx +++ b/apps/with-lingui/src/routes/_auth/users/index.tsx @@ -3,7 +3,7 @@ import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "an import type { TablePaginationConfig } from "antd/es/table/interface"; import { useLingui } from "@lingui/react/macro"; import { useMemo, useRef, useState } from "react"; -import { createUser, deleteUser, listUsers, updateUser, userQueryKeys } from "@/api/user"; +import { createUser, deleteUser, listUsers, updateUser, userQueryKeys } from "@/api/users"; import type { User, CreateUserRequest } from "@/api/schemas"; import { z } from "zod/v4"; import { MoreVertical, Pencil, Trash2 } from "lucide-react";