Flesh out API layer
This commit is contained in:
parent
fdc57c45f4
commit
cbd92e2d52
6 changed files with 1033 additions and 32 deletions
|
|
@ -1,11 +1,7 @@
|
|||
import { PUBLIC_API_BASE } from '$env/static/public'
|
||||
|
||||
export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
import type { FetchLike } from '$lib/api/core'
|
||||
import { buildUrl, json } from '$lib/api/core'
|
||||
|
||||
export async function getJson<T>(path: string, fetchFn: FetchLike, init?: RequestInit): Promise<T> {
|
||||
const base = PUBLIC_API_BASE || ''
|
||||
const url = path.startsWith('http') ? path : `${base}${path}`
|
||||
const res = await fetchFn(url, { credentials: 'include', ...init })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`)
|
||||
return res.json() as Promise<T>
|
||||
const url = buildUrl(path)
|
||||
return json<T>(fetchFn, url, init)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,39 +3,54 @@ import { PUBLIC_SIERO_API_URL } from '$env/static/public'
|
|||
export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||
export type Dict = Record<string, unknown>
|
||||
|
||||
const API = PUBLIC_SIERO_API_URL?.replace(/\/$/, '') ?? 'http://localhost:3000/api/v1'
|
||||
// Compute a stable API base that always includes the versioned prefix.
|
||||
function computeApiBase(): string {
|
||||
const raw = (PUBLIC_SIERO_API_URL || 'http://localhost:3000') as string
|
||||
const u = new URL(raw, raw.startsWith('http') ? undefined : 'http://localhost')
|
||||
const origin = u.origin
|
||||
const path = u.pathname.replace(/\/$/, '')
|
||||
const hasVersion = /(\/api\/v1|\/v1)$/.test(path)
|
||||
const basePath = hasVersion ? path : `${path}/api/v1`
|
||||
return `${origin}${basePath}`
|
||||
}
|
||||
|
||||
export const API_BASE = computeApiBase()
|
||||
|
||||
export function buildUrl(path: string, params?: Dict) {
|
||||
const url = new URL(path.startsWith('http') ? path : `${API}${path}`, API)
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (Array.isArray(value)) value.forEach((x) => url.searchParams.append(key, String(x)))
|
||||
else url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
return url.toString()
|
||||
const url = new URL(path.startsWith('http') ? path : `${API_BASE}${path}`, API_BASE)
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null) continue
|
||||
if (Array.isArray(value)) value.forEach((x) => url.searchParams.append(key, String(x)))
|
||||
else url.searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export async function json<T>(fetchFn: FetchLike, url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetchFn(url, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
||||
...init
|
||||
})
|
||||
const res = await fetchFn(url, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
||||
...init
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`)
|
||||
return res.json() as Promise<T>
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`)
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const get = <T>(f: FetchLike, path: string, params?: Dict, init?: RequestInit) =>
|
||||
json<T>(f, buildUrl(path, params), init)
|
||||
json<T>(f, buildUrl(path, params), init)
|
||||
|
||||
export const post = <T>(f: FetchLike, path: string, body?: unknown, init?: RequestInit) =>
|
||||
json<T>(f, path, { method: 'POST', body: body ? JSON.stringify(body) : undefined, ...init })
|
||||
export const post = <T>(f: FetchLike, path: string, body?: unknown, init?: RequestInit) => {
|
||||
const extra = body !== undefined ? { body: JSON.stringify(body) } : {}
|
||||
return json<T>(f, buildUrl(path), { method: 'POST', ...extra, ...init })
|
||||
}
|
||||
|
||||
export const put = <T>(f: FetchLike, path: string, body?: unknown, init?: RequestInit) =>
|
||||
json<T>(f, path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined, ...init })
|
||||
export const put = <T>(f: FetchLike, path: string, body?: unknown, init?: RequestInit) => {
|
||||
const extra = body !== undefined ? { body: JSON.stringify(body) } : {}
|
||||
return json<T>(f, buildUrl(path), { method: 'PUT', ...extra, ...init })
|
||||
}
|
||||
|
||||
export const del = <T>(f: FetchLike, path: string, init?: RequestInit) =>
|
||||
json<T>(f, path, { method: 'DELETE', ...init })
|
||||
json<T>(f, buildUrl(path), { method: 'DELETE', ...init })
|
||||
|
|
|
|||
|
|
@ -0,0 +1,382 @@
|
|||
import { buildUrl, get, post, put, del, type FetchLike } from '$lib/api/core'
|
||||
import { parseParty, type Party } from '$lib/api/schemas/party'
|
||||
import { camelToSnake } from '$lib/api/schemas/transforms'
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Party API resource functions
|
||||
*/
|
||||
|
||||
// Response schemas
|
||||
// Note: The API returns snake_case; we validate with raw schemas and
|
||||
// convert to camelCase via parseParty() at the edge.
|
||||
const PartyResponseSchema = z.object({
|
||||
party: z.any() // We'll validate after extracting
|
||||
})
|
||||
|
||||
const PartiesResponseSchema = z.object({
|
||||
parties: z.array(z.any()),
|
||||
total: z.number().optional()
|
||||
})
|
||||
|
||||
const ConflictResponseSchema = z.object({
|
||||
conflicts: z.array(z.string()),
|
||||
incoming: z.string(),
|
||||
position: z.number()
|
||||
})
|
||||
|
||||
const PaginatedPartiesSchema = z.object({
|
||||
results: z.array(z.any()),
|
||||
meta: z
|
||||
.object({
|
||||
count: z.number().optional(),
|
||||
total_pages: z.number().optional(),
|
||||
per_page: z.number().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
// API functions
|
||||
export async function getByShortcode(fetch: FetchLike, shortcode: string): Promise<Party> {
|
||||
const url = buildUrl(`/parties/${encodeURIComponent(shortcode)}`)
|
||||
const res = await fetch(url, { credentials: 'include' })
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await parseError(res)
|
||||
throw error
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
// Extract the party object (API returns { party: {...} })
|
||||
const partyData = json?.party || json
|
||||
|
||||
// Validate and transform snake_case to camelCase
|
||||
const parsed = parseParty(partyData)
|
||||
return parsed
|
||||
}
|
||||
|
||||
export async function create(
|
||||
fetch: FetchLike,
|
||||
payload: Partial<Party>,
|
||||
headers?: Record<string, string>
|
||||
): Promise<Party> {
|
||||
const body = camelToSnake(payload)
|
||||
const res = await fetch(buildUrl('/parties'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await parseError(res)
|
||||
throw error
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
const parsed = PartyResponseSchema.parse(json)
|
||||
return parseParty(parsed.party)
|
||||
}
|
||||
|
||||
export async function list(
|
||||
fetch: FetchLike,
|
||||
params?: { page?: number }
|
||||
): Promise<{ items: Party[]; total?: number; totalPages?: number; perPage?: number; page: number }> {
|
||||
const page = params?.page && params.page > 0 ? params.page : 1
|
||||
const url = buildUrl('/parties', { page })
|
||||
const res = await fetch(url, { credentials: 'include' })
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await parseError(res)
|
||||
throw error
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
// Controller returns { results: [...], meta: { count, total_pages, per_page } }
|
||||
const parsed = PaginatedPartiesSchema.parse(json)
|
||||
const items = (parsed.results || []).map(parseParty)
|
||||
return {
|
||||
items,
|
||||
total: parsed.meta?.count,
|
||||
totalPages: parsed.meta?.total_pages,
|
||||
perPage: parsed.meta?.per_page,
|
||||
page
|
||||
}
|
||||
}
|
||||
|
||||
export async function favorites(
|
||||
fetch: FetchLike,
|
||||
params?: { page?: number }
|
||||
): Promise<{ items: Party[]; total?: number; totalPages?: number; perPage?: number; page: number }> {
|
||||
const page = params?.page && params.page > 0 ? params.page : 1
|
||||
const url = buildUrl('/parties/favorites', { page })
|
||||
const res = await fetch(url, { credentials: 'include' })
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await parseError(res)
|
||||
throw error
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
const parsed = PaginatedPartiesSchema.parse(json)
|
||||
const items = (parsed.results || []).map(parseParty)
|
||||
return {
|
||||
items,
|
||||
total: parsed.meta?.count,
|
||||
totalPages: parsed.meta?.total_pages,
|
||||
perPage: parsed.meta?.per_page,
|
||||
page
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(
|
||||
fetch: FetchLike,
|
||||
id: string,
|
||||
payload: Partial<Party>,
|
||||
headers?: Record<string, string>
|
||||
): Promise<Party> {
|
||||
const body = camelToSnake(payload)
|
||||
const res = await fetch(buildUrl(`/parties/${id}`), {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await parseError(res)
|
||||
throw error
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
|
||||
// Handle conflict response
|
||||
if ('conflicts' in json) {
|
||||
const conflict = ConflictResponseSchema.parse(json)
|
||||
throw {
|
||||
type: 'conflict',
|
||||
...conflict
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = PartyResponseSchema.parse(json)
|
||||
return parseParty(parsed.party)
|
||||
}
|
||||
|
||||
export async function remix(
|
||||
fetch: FetchLike,
|
||||
shortcode: string,
|
||||
localId?: string,
|
||||
headers?: Record<string, string>
|
||||
): Promise<{ party: Party; editKey?: string }> {
|
||||
const body = localId ? { local_id: localId } : {}
|
||||
const res = await fetch(buildUrl(`/parties/${encodeURIComponent(shortcode)}/remix`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await parseError(res)
|
||||
throw error
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
const parsed = PartyResponseSchema.parse(json)
|
||||
|
||||
// Check for edit_key in response
|
||||
const editKey = (json as any).edit_key
|
||||
|
||||
return {
|
||||
party: parseParty(parsed.party),
|
||||
editKey
|
||||
}
|
||||
}
|
||||
|
||||
export async function favorite(
|
||||
fetch: FetchLike,
|
||||
id: string,
|
||||
headers?: Record<string, string>
|
||||
): Promise<void> {
|
||||
const res = await fetch(buildUrl(`/parties/${id}/favorite`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
}
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await parseError(res)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function unfavorite(
|
||||
fetch: FetchLike,
|
||||
id: string,
|
||||
headers?: Record<string, string>
|
||||
): Promise<void> {
|
||||
const res = await fetch(buildUrl(`/parties/${id}/unfavorite`), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
}
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await parseError(res)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteParty(
|
||||
fetch: FetchLike,
|
||||
id: string,
|
||||
headers?: Record<string, string>
|
||||
): Promise<void> {
|
||||
const res = await fetch(buildUrl(`/parties/${id}`), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
}
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await parseError(res)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Grid update functions
|
||||
export async function updateWeaponGrid(
|
||||
fetch: FetchLike,
|
||||
partyId: string,
|
||||
payload: any,
|
||||
headers?: Record<string, string>
|
||||
): Promise<Party> {
|
||||
const body = camelToSnake(payload)
|
||||
const res = await fetch(buildUrl(`/parties/${partyId}/grid_weapons`), {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await parseError(res)
|
||||
throw error
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
|
||||
// Handle conflict response
|
||||
if ('conflicts' in json) {
|
||||
const conflict = ConflictResponseSchema.parse(json)
|
||||
throw {
|
||||
type: 'conflict',
|
||||
...conflict
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = PartyResponseSchema.parse(json)
|
||||
return parseParty(parsed.party)
|
||||
}
|
||||
|
||||
export async function updateSummonGrid(
|
||||
fetch: FetchLike,
|
||||
partyId: string,
|
||||
payload: any,
|
||||
headers?: Record<string, string>
|
||||
): Promise<Party> {
|
||||
const body = camelToSnake(payload)
|
||||
const res = await fetch(buildUrl(`/parties/${partyId}/grid_summons`), {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await parseError(res)
|
||||
throw error
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
const parsed = PartyResponseSchema.parse(json)
|
||||
return parseParty(parsed.party)
|
||||
}
|
||||
|
||||
export async function updateCharacterGrid(
|
||||
fetch: FetchLike,
|
||||
partyId: string,
|
||||
payload: any,
|
||||
headers?: Record<string, string>
|
||||
): Promise<Party> {
|
||||
const body = camelToSnake(payload)
|
||||
const res = await fetch(buildUrl(`/parties/${partyId}/grid_characters`), {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await parseError(res)
|
||||
throw error
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
|
||||
// Handle conflict response
|
||||
if ('conflicts' in json) {
|
||||
const conflict = ConflictResponseSchema.parse(json)
|
||||
throw {
|
||||
type: 'conflict',
|
||||
...conflict
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = PartyResponseSchema.parse(json)
|
||||
return parseParty(parsed.party)
|
||||
}
|
||||
|
||||
// Helper to parse API errors
|
||||
async function parseError(res: Response): Promise<Error> {
|
||||
let message = res.statusText || 'Request failed'
|
||||
|
||||
try {
|
||||
const data = await res.clone().json()
|
||||
if (typeof data?.error === 'string') message = data.error
|
||||
else if (typeof data?.message === 'string') message = data.message
|
||||
else if (Array.isArray(data?.errors)) message = data.errors.join(', ')
|
||||
} catch {}
|
||||
|
||||
const error = new Error(message) as any
|
||||
error.status = res.status
|
||||
return error
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { FetchLike } from '../core'
|
||||
import { get } from '../core'
|
||||
import { get, buildUrl } from '../core'
|
||||
|
||||
export interface UserInfoResponse {
|
||||
id: string
|
||||
|
|
@ -19,3 +19,29 @@ export const users = {
|
|||
info: (f: FetchLike, username: string, init?: RequestInit) =>
|
||||
get<UserInfoResponse>(f, `/users/info/${encodeURIComponent(username)}`, undefined, init)
|
||||
}
|
||||
|
||||
export interface UserProfileResponse {
|
||||
profile: UserInfoResponse & { parties?: any[] }
|
||||
meta?: { count?: number; total_pages?: number; per_page?: number }
|
||||
}
|
||||
|
||||
export async function profile(
|
||||
f: FetchLike,
|
||||
username: string,
|
||||
page?: number
|
||||
): Promise<{ user: UserInfoResponse; items: any[]; page: number; total?: number; totalPages?: number; perPage?: number }> {
|
||||
const qs = page && page > 1 ? { page } : undefined
|
||||
const url = buildUrl(`/users/${encodeURIComponent(username)}`, qs as any)
|
||||
const resp = await f(url, { credentials: 'include' })
|
||||
if (!resp.ok) throw new Error(resp.statusText || 'Failed to load profile')
|
||||
const json = (await resp.json()) as UserProfileResponse
|
||||
const items = Array.isArray(json.profile?.parties) ? json.profile.parties : []
|
||||
return {
|
||||
user: json.profile as any,
|
||||
items,
|
||||
page: page || 1,
|
||||
total: json.meta?.count,
|
||||
totalPages: json.meta?.total_pages,
|
||||
perPage: json.meta?.per_page
|
||||
}
|
||||
}
|
||||
|
|
|
|||
539
src/lib/api/schemas/party.ts
Normal file
539
src/lib/api/schemas/party.ts
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
import { z } from 'zod'
|
||||
import { snakeToCamel } from './transforms'
|
||||
|
||||
// Minimal camelCase validation to start small and safe
|
||||
const MinimalCamelPartySchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
shortcode: z.string(),
|
||||
user: z
|
||||
.object({ id: z.string().optional() })
|
||||
.nullish()
|
||||
.optional(),
|
||||
localId: z.string().nullish().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
// Minimal TS types for UI (grid view)
|
||||
export type LocalizedName = string | { en?: string | null; ja?: string | null }
|
||||
export interface NamedObject { name?: LocalizedName | null; [k: string]: unknown }
|
||||
export interface GridWeaponItemView { position: number; mainhand?: boolean | null; object?: NamedObject | null; [k: string]: unknown }
|
||||
export interface GridSummonItemView { position: number; main?: boolean | null; friend?: boolean | null; quickSummon?: boolean | null; object?: NamedObject | null; [k: string]: unknown }
|
||||
export interface GridCharacterItemView { position: number; perpetuity?: boolean | null; transcendenceStep?: number | null; object?: NamedObject | null; [k: string]: unknown }
|
||||
|
||||
export interface PartyView {
|
||||
id: string
|
||||
shortcode: string
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
user?: { id?: string | null } | null
|
||||
localId?: string | null
|
||||
favorited?: boolean
|
||||
fullAuto?: boolean
|
||||
autoGuard?: boolean
|
||||
autoSummon?: boolean
|
||||
chargeAttack?: boolean
|
||||
clearTime?: number
|
||||
buttonCount?: number
|
||||
chainCount?: number
|
||||
turnCount?: number
|
||||
visibility?: number
|
||||
raid?: { name?: LocalizedName | null; group?: { difficulty?: number | null; extra?: boolean | null; guidebooks?: boolean | null; [k: string]: unknown } | null; [k: string]: unknown } | null
|
||||
job?: { name?: LocalizedName | null; [k: string]: unknown } | null
|
||||
weapons: GridWeaponItemView[]
|
||||
summons: GridSummonItemView[]
|
||||
characters: GridCharacterItemView[]
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
// Helper for localized names
|
||||
const LocalizedNameSchema = z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
en: z.string().nullish(),
|
||||
ja: z.string().nullish()
|
||||
})
|
||||
])
|
||||
|
||||
// Minimal grid item guards (post camelCase)
|
||||
const MinimalGridWeaponItemSchema = z
|
||||
.object({
|
||||
position: z.number(),
|
||||
mainhand: z.boolean().nullish().optional(),
|
||||
object: z
|
||||
.object({
|
||||
name: LocalizedNameSchema.nullish().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.nullish()
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const MinimalGridSummonItemSchema = z
|
||||
.object({
|
||||
position: z.number(),
|
||||
main: z.boolean().nullish().optional(),
|
||||
friend: z.boolean().nullish().optional(),
|
||||
quickSummon: z.boolean().nullish().optional(),
|
||||
object: z
|
||||
.object({
|
||||
name: LocalizedNameSchema.nullish().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.nullish()
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const MinimalGridCharacterItemSchema = z
|
||||
.object({
|
||||
position: z.number(),
|
||||
perpetuity: z.boolean().nullish().optional(),
|
||||
transcendenceStep: z.number().nullish().optional(),
|
||||
object: z
|
||||
.object({
|
||||
name: LocalizedNameSchema.nullish().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.nullish()
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const MinimalGridsSchema = z
|
||||
.object({
|
||||
weapons: z.array(MinimalGridWeaponItemSchema).optional(),
|
||||
summons: z.array(MinimalGridSummonItemSchema).optional(),
|
||||
characters: z.array(MinimalGridCharacterItemSchema).optional()
|
||||
})
|
||||
.partial()
|
||||
|
||||
// Minimal header associations (raid/job) and core scalar flags/counters
|
||||
const MinimalHeaderSchema = z
|
||||
.object({
|
||||
raid: z
|
||||
.object({
|
||||
name: LocalizedNameSchema.nullish().optional(),
|
||||
group: z
|
||||
.object({
|
||||
difficulty: z.number().nullish().optional(),
|
||||
extra: z.boolean().nullish().optional(),
|
||||
guidebooks: z.boolean().nullish().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.nullish()
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
.nullish()
|
||||
.optional(),
|
||||
job: z
|
||||
.object({
|
||||
name: LocalizedNameSchema.nullish().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.nullish()
|
||||
.optional()
|
||||
})
|
||||
.partial()
|
||||
|
||||
const MinimalScalarsSchema = z
|
||||
.object({
|
||||
favorited: z.boolean().nullish().optional(),
|
||||
fullAuto: z.boolean().nullish().optional(),
|
||||
autoGuard: z.boolean().nullish().optional(),
|
||||
autoSummon: z.boolean().nullish().optional(),
|
||||
chargeAttack: z.boolean().nullish().optional(),
|
||||
clearTime: z.number().nullish().optional(),
|
||||
buttonCount: z.number().nullish().optional(),
|
||||
chainCount: z.number().nullish().optional(),
|
||||
turnCount: z.number().nullish().optional(),
|
||||
visibility: z.number().nullish().optional()
|
||||
})
|
||||
.partial()
|
||||
|
||||
// User schema
|
||||
const UserSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
role: z.number().optional(),
|
||||
granblue_id: z.number().nullish(),
|
||||
avatar_url: z.string().nullish(),
|
||||
crew_name: z.string().nullish()
|
||||
})
|
||||
|
||||
// Raid and RaidGroup schemas
|
||||
const RaidGroupSchema = z.object({
|
||||
id: z.string(),
|
||||
name: LocalizedNameSchema,
|
||||
difficulty: z.number(),
|
||||
section: z.union([z.string(), z.number()]), // Can be number or string
|
||||
order: z.number(),
|
||||
extra: z.boolean().nullish(),
|
||||
guidebooks: z.boolean().nullish()
|
||||
})
|
||||
|
||||
const RaidSchema = z.object({
|
||||
id: z.string(),
|
||||
name: LocalizedNameSchema,
|
||||
slug: z.string().nullish(),
|
||||
group: RaidGroupSchema.nullish(),
|
||||
element: z.number().nullish()
|
||||
})
|
||||
|
||||
// Job related schemas
|
||||
const JobSchema = z.object({
|
||||
id: z.string(),
|
||||
name: LocalizedNameSchema,
|
||||
name_jp: z.string().optional(),
|
||||
job_type: z.number().nullish(),
|
||||
accessory_type: z.number().nullish(),
|
||||
proficiency1: z.number().nullish(),
|
||||
proficiency2: z.number().nullish(),
|
||||
row: z.union([z.string(), z.number()]).nullish(),
|
||||
order: z.number().nullish()
|
||||
})
|
||||
|
||||
const JobSkillSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
name_jp: z.string().optional(),
|
||||
slug: z.string(),
|
||||
cooldown: z.number().nullish(),
|
||||
description: z.string().nullish(),
|
||||
description_jp: z.string().nullish(),
|
||||
main: z.boolean(),
|
||||
sub: z.boolean(),
|
||||
emp: z.boolean()
|
||||
})
|
||||
|
||||
const JobAccessorySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
name_jp: z.string().optional(),
|
||||
slug: z.string()
|
||||
})
|
||||
|
||||
// Item schemas
|
||||
const WeaponSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
name_jp: z.string().optional(),
|
||||
slug: z.string().nullish(),
|
||||
granblue_id: z.number().nullish(),
|
||||
element: z.number().nullish(),
|
||||
proficiency1: z.number().nullish(),
|
||||
proficiency2: z.number().nullish(),
|
||||
rarity: z.number().nullish(),
|
||||
max_level: z.number().nullish(),
|
||||
max_skill_level: z.number().nullish(),
|
||||
series: z.number().nullish(),
|
||||
icon_url: z.string().nullish(),
|
||||
square_url: z.string().nullish()
|
||||
})
|
||||
|
||||
const SummonSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
name_jp: z.string().optional(),
|
||||
slug: z.string().nullish(),
|
||||
granblue_id: z.number().nullish(),
|
||||
element: z.number().nullish(),
|
||||
rarity: z.number().nullish(),
|
||||
max_level: z.number().nullish(),
|
||||
icon_url: z.string().nullish(),
|
||||
square_url: z.string().nullish(),
|
||||
main: z.boolean().optional(),
|
||||
friend: z.boolean().optional(),
|
||||
subaura: z.boolean().optional()
|
||||
})
|
||||
|
||||
const CharacterSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
name_jp: z.string().optional(),
|
||||
slug: z.string().nullish(),
|
||||
granblue_id: z.number().nullish(),
|
||||
element: z.number().nullish(),
|
||||
rarity: z.number().nullish(),
|
||||
max_level: z.number().nullish(),
|
||||
proficiency1: z.number().nullish(),
|
||||
proficiency2: z.number().nullish(),
|
||||
icon_url: z.string().nullish(),
|
||||
square_url: z.string().nullish(),
|
||||
special: z.boolean().optional()
|
||||
})
|
||||
|
||||
const GuidebookSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
name_jp: z.string().optional(),
|
||||
slug: z.string(),
|
||||
description: z.string().nullish(),
|
||||
description_jp: z.string().nullish(),
|
||||
granblue_id: z.number(),
|
||||
icon_url: z.string().nullish(),
|
||||
square_url: z.string().nullish()
|
||||
})
|
||||
|
||||
// Ring/Earring schema for characters
|
||||
const RingSchema = z.object({
|
||||
modifier: z.number().nullish(),
|
||||
strength: z.number().nullish()
|
||||
}).nullish()
|
||||
|
||||
// Grid schemas
|
||||
const GridWeaponSchema = z.object({
|
||||
id: z.string(),
|
||||
party_id: z.string().nullish(),
|
||||
weapon_id: z.string().nullish(),
|
||||
position: z.number(),
|
||||
mainhand: z.boolean().nullish(),
|
||||
uncap_level: z.number().nullish().default(0),
|
||||
transcendence_step: z.number().nullish().default(0),
|
||||
transcendence_level: z.number().nullish().default(0), // Alias for compatibility
|
||||
element: z.number().nullish(),
|
||||
|
||||
// Weapon keys
|
||||
weapon_key1_id: z.string().nullish(),
|
||||
weapon_key2_id: z.string().nullish(),
|
||||
weapon_key3_id: z.string().nullish(),
|
||||
weapon_key4_id: z.string().nullish(),
|
||||
weapon_keys: z.array(z.any()).nullish(), // Populated by API with key details
|
||||
|
||||
// Awakening
|
||||
awakening_id: z.string().nullish(),
|
||||
awakening_level: z.number().nullish().default(1),
|
||||
awakening: z.any().nullish(), // Populated by API with awakening details
|
||||
|
||||
// AX modifiers
|
||||
ax_modifier1: z.number().nullish(),
|
||||
ax_strength1: z.number().nullish(),
|
||||
ax_modifier2: z.number().nullish(),
|
||||
ax_strength2: z.number().nullish(),
|
||||
|
||||
// Nested weapon data (populated by API)
|
||||
weapon: WeaponSchema.nullish(),
|
||||
|
||||
created_at: z.string().nullish(),
|
||||
updated_at: z.string().nullish()
|
||||
})
|
||||
|
||||
const GridSummonSchema = z.object({
|
||||
id: z.string(),
|
||||
party_id: z.string().nullish(),
|
||||
summon_id: z.string().nullish(),
|
||||
position: z.number(),
|
||||
main: z.boolean().nullish(),
|
||||
friend: z.boolean().nullish(),
|
||||
uncap_level: z.number().nullish().default(0),
|
||||
transcendence_step: z.number().nullish().default(0),
|
||||
transcendence_level: z.number().nullish().default(0), // Alias for compatibility
|
||||
quick_summon: z.boolean().nullish().default(false),
|
||||
|
||||
// Nested summon data (populated by API)
|
||||
summon: SummonSchema.nullish(),
|
||||
|
||||
created_at: z.string().nullish(),
|
||||
updated_at: z.string().nullish()
|
||||
})
|
||||
|
||||
const GridCharacterSchema = z.object({
|
||||
id: z.string(),
|
||||
party_id: z.string().nullish(),
|
||||
character_id: z.string().nullish(),
|
||||
position: z.number(),
|
||||
uncap_level: z.number().nullish().default(0),
|
||||
transcendence_step: z.number().nullish().default(0),
|
||||
transcendence_level: z.number().nullish().default(0), // Alias for compatibility
|
||||
perpetuity: z.boolean().nullish().default(false),
|
||||
|
||||
// Rings and earring
|
||||
ring1: RingSchema,
|
||||
ring2: RingSchema,
|
||||
ring3: RingSchema,
|
||||
ring4: RingSchema,
|
||||
earring: RingSchema,
|
||||
|
||||
// Awakening
|
||||
awakening_id: z.string().nullish(),
|
||||
awakening_level: z.number().nullish().default(1),
|
||||
|
||||
// Nested character data (populated by API)
|
||||
character: CharacterSchema.nullish(),
|
||||
|
||||
// Legacy field
|
||||
over_mastery_level: z.number().nullish(),
|
||||
|
||||
created_at: z.string().nullish(),
|
||||
updated_at: z.string().nullish()
|
||||
})
|
||||
|
||||
// Main Party schema - raw without transform
|
||||
export const PartySchemaRaw = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
shortcode: z.string(),
|
||||
visibility: z.number().nullish().default(1),
|
||||
element: z.number().nullish(),
|
||||
|
||||
// Battle settings
|
||||
full_auto: z.boolean().nullish().default(false),
|
||||
auto_guard: z.boolean().nullish().default(false),
|
||||
auto_summon: z.boolean().nullish().default(false),
|
||||
charge_attack: z.boolean().nullish().default(true),
|
||||
|
||||
// Performance metrics
|
||||
clear_time: z.number().nullish().default(0),
|
||||
button_count: z.number().nullish(),
|
||||
turn_count: z.number().nullish(),
|
||||
chain_count: z.number().nullish(),
|
||||
|
||||
// Relations
|
||||
raid_id: z.string().nullish(),
|
||||
raid: RaidSchema.nullish(),
|
||||
job_id: z.string().nullish(),
|
||||
job: JobSchema.nullish(),
|
||||
user_id: z.string().nullish(),
|
||||
user: UserSchema.nullish(),
|
||||
|
||||
// Job details
|
||||
master_level: z.number().nullish(),
|
||||
ultimate_mastery: z.number().nullish(),
|
||||
skill0_id: z.string().nullish(),
|
||||
skill1_id: z.string().nullish(),
|
||||
skill2_id: z.string().nullish(),
|
||||
skill3_id: z.string().nullish(),
|
||||
job_skills: z.union([
|
||||
z.array(z.any()),
|
||||
z.record(z.any())
|
||||
]).nullish().default([]),
|
||||
accessory_id: z.string().nullish(),
|
||||
accessory: JobAccessorySchema.nullish(),
|
||||
|
||||
// Guidebooks
|
||||
guidebook1_id: z.string().nullish(),
|
||||
guidebook2_id: z.string().nullish(),
|
||||
guidebook3_id: z.string().nullish(),
|
||||
guidebooks: z.union([
|
||||
z.array(z.any()),
|
||||
z.record(z.any())
|
||||
]).nullish().default([]),
|
||||
|
||||
// Grid arrays (may be empty or contain items with missing nested data)
|
||||
characters: z.array(GridCharacterSchema).nullish().default([]),
|
||||
weapons: z.array(GridWeaponSchema).nullish().default([]),
|
||||
summons: z.array(GridSummonSchema).nullish().default([]),
|
||||
|
||||
// Counts
|
||||
weapons_count: z.number().nullish().default(0),
|
||||
characters_count: z.number().nullish().default(0),
|
||||
summons_count: z.number().nullish().default(0),
|
||||
|
||||
// Metadata
|
||||
extra: z.boolean().nullish().default(false),
|
||||
favorited: z.boolean().nullish().default(false),
|
||||
remix: z.boolean().nullish().default(false),
|
||||
local_id: z.string().nullish(),
|
||||
edit_key: z.string().nullish(),
|
||||
source_party_id: z.string().nullish(),
|
||||
source_party: z.any().nullish(),
|
||||
remixes: z.array(z.any()).nullish().default([]),
|
||||
|
||||
// Preview
|
||||
preview_state: z.number().nullish().default(0),
|
||||
preview_generated_at: z.string().nullish(),
|
||||
preview_s3_key: z.string().nullish(),
|
||||
|
||||
// Timestamps
|
||||
created_at: z.string().nullish(),
|
||||
updated_at: z.string().nullish()
|
||||
})
|
||||
|
||||
// Apply transform after parsing (do NOT nest this schema inside other schemas)
|
||||
// Keep exported for typing only; prefer parseParty() for runtime parsing.
|
||||
export const PartySchema = PartySchemaRaw.transform(snakeToCamel)
|
||||
|
||||
// Minimal schema for nested references
|
||||
export const PartyMinimalSchema = z.object({
|
||||
id: z.string(),
|
||||
shortcode: z.string(),
|
||||
name: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
user: UserSchema.nullish()
|
||||
}).transform(snakeToCamel)
|
||||
|
||||
// Export types
|
||||
// Type-level snake_case -> camelCase mapping
|
||||
type CamelCase<S extends string> = S extends `${infer P}_${infer R}`
|
||||
? `${P}${Capitalize<CamelCase<R>>}`
|
||||
: S
|
||||
|
||||
type CamelCasedKeysDeep<T> = T extends Array<infer U>
|
||||
? Array<CamelCasedKeysDeep<U>>
|
||||
: T extends object
|
||||
? { [K in keyof T as K extends string ? CamelCase<K> : K]: CamelCasedKeysDeep<T[K]> }
|
||||
: T
|
||||
|
||||
export type Party = CamelCasedKeysDeep<z.infer<typeof PartySchemaRaw>>
|
||||
export type PartyMinimal = CamelCasedKeysDeep<z.infer<typeof PartyMinimalSchema>>
|
||||
export type GridWeapon = CamelCasedKeysDeep<z.infer<typeof GridWeaponSchema>>
|
||||
export type GridSummon = CamelCasedKeysDeep<z.infer<typeof GridSummonSchema>>
|
||||
export type GridCharacter = CamelCasedKeysDeep<z.infer<typeof GridCharacterSchema>>
|
||||
export type Weapon = CamelCasedKeysDeep<z.infer<typeof WeaponSchema>>
|
||||
export type Summon = CamelCasedKeysDeep<z.infer<typeof SummonSchema>>
|
||||
export type Character = CamelCasedKeysDeep<z.infer<typeof CharacterSchema>>
|
||||
export type Job = CamelCasedKeysDeep<z.infer<typeof JobSchema>>
|
||||
export type JobSkill = CamelCasedKeysDeep<z.infer<typeof JobSkillSchema>>
|
||||
export type JobAccessory = CamelCasedKeysDeep<z.infer<typeof JobAccessorySchema>>
|
||||
export type Raid = CamelCasedKeysDeep<z.infer<typeof RaidSchema>>
|
||||
export type RaidGroup = CamelCasedKeysDeep<z.infer<typeof RaidGroupSchema>>
|
||||
export type User = CamelCasedKeysDeep<z.infer<typeof UserSchema>>
|
||||
export type Guidebook = CamelCasedKeysDeep<z.infer<typeof GuidebookSchema>>
|
||||
|
||||
// Helper: parse raw API party (snake_case) and convert to camelCase
|
||||
export function parseParty(input: unknown): Party {
|
||||
// Step 1: convert server snake_case to client camelCase
|
||||
const camel = snakeToCamel(input) as Record<string, unknown>
|
||||
// Step 2: validate only minimal, core fields and pass through the rest
|
||||
const parsed = MinimalCamelPartySchema.parse(camel) as any
|
||||
|
||||
// Step 3: minimally validate grids and coerce to arrays for UI safety
|
||||
const grids = MinimalGridsSchema.safeParse(camel)
|
||||
if (grids.success) {
|
||||
parsed.weapons = grids.data.weapons ?? []
|
||||
parsed.summons = grids.data.summons ?? []
|
||||
parsed.characters = grids.data.characters ?? []
|
||||
} else {
|
||||
parsed.weapons = Array.isArray(parsed.weapons) ? parsed.weapons : []
|
||||
parsed.summons = Array.isArray(parsed.summons) ? parsed.summons : []
|
||||
parsed.characters = Array.isArray(parsed.characters) ? parsed.characters : []
|
||||
}
|
||||
|
||||
// Step 4: minimally validate header associations (raid/job)
|
||||
const hdr = MinimalHeaderSchema.safeParse(camel)
|
||||
if (hdr.success) {
|
||||
if (hdr.data.raid !== undefined) parsed.raid = hdr.data.raid
|
||||
if (hdr.data.job !== undefined) parsed.job = hdr.data.job
|
||||
}
|
||||
|
||||
// Step 5: core scalars with safe defaults for UI
|
||||
const scal = MinimalScalarsSchema.safeParse(camel)
|
||||
const pick = <T>(v: T | null | undefined, d: T) => (v ?? d)
|
||||
if (scal.success) {
|
||||
parsed.favorited = pick(scal.data.favorited ?? (parsed as any).favorited, false)
|
||||
parsed.fullAuto = pick(scal.data.fullAuto ?? (parsed as any).fullAuto, false)
|
||||
parsed.autoGuard = pick(scal.data.autoGuard ?? (parsed as any).autoGuard, false)
|
||||
parsed.autoSummon = pick(scal.data.autoSummon ?? (parsed as any).autoSummon, false)
|
||||
parsed.chargeAttack = pick(scal.data.chargeAttack ?? (parsed as any).chargeAttack, true)
|
||||
parsed.clearTime = pick(scal.data.clearTime ?? (parsed as any).clearTime, 0)
|
||||
parsed.buttonCount = pick(scal.data.buttonCount ?? (parsed as any).buttonCount, 0)
|
||||
parsed.chainCount = pick(scal.data.chainCount ?? (parsed as any).chainCount, 0)
|
||||
parsed.turnCount = pick(scal.data.turnCount ?? (parsed as any).turnCount, 0)
|
||||
parsed.visibility = pick(scal.data.visibility ?? (parsed as any).visibility, 1)
|
||||
}
|
||||
|
||||
return parsed as Party
|
||||
}
|
||||
43
src/lib/api/schemas/transforms.ts
Normal file
43
src/lib/api/schemas/transforms.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Transforms snake_case keys to camelCase
|
||||
*/
|
||||
export function snakeToCamel<T>(obj: T): T {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(snakeToCamel) as T
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const result: any = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||
result[camelKey] = snakeToCamel(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms camelCase keys to snake_case
|
||||
*/
|
||||
export function camelToSnake<T>(obj: T): T {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(camelToSnake) as T
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const result: any = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const snakeKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`)
|
||||
result[snakeKey] = camelToSnake(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
Loading…
Reference in a new issue