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'
|
import type { FetchLike } from '$lib/api/core'
|
||||||
|
import { buildUrl, json } from '$lib/api/core'
|
||||||
export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
||||||
|
|
||||||
export async function getJson<T>(path: string, fetchFn: FetchLike, init?: RequestInit): Promise<T> {
|
export async function getJson<T>(path: string, fetchFn: FetchLike, init?: RequestInit): Promise<T> {
|
||||||
const base = PUBLIC_API_BASE || ''
|
const url = buildUrl(path)
|
||||||
const url = path.startsWith('http') ? path : `${base}${path}`
|
return json<T>(fetchFn, url, init)
|
||||||
const res = await fetchFn(url, { credentials: 'include', ...init })
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`)
|
|
||||||
return res.json() as Promise<T>
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||||
export type Dict = Record<string, unknown>
|
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) {
|
export function buildUrl(path: string, params?: Dict) {
|
||||||
const url = new URL(path.startsWith('http') ? path : `${API}${path}`, API)
|
const url = new URL(path.startsWith('http') ? path : `${API_BASE}${path}`, API_BASE)
|
||||||
if (params) {
|
if (params) {
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
if (value === undefined || value === null) continue
|
if (value === undefined || value === null) continue
|
||||||
if (Array.isArray(value)) value.forEach((x) => url.searchParams.append(key, String(x)))
|
if (Array.isArray(value)) value.forEach((x) => url.searchParams.append(key, String(x)))
|
||||||
else url.searchParams.set(key, String(value))
|
else url.searchParams.set(key, String(value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return url.toString()
|
return url.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function json<T>(fetchFn: FetchLike, url: string, init?: RequestInit): Promise<T> {
|
export async function json<T>(fetchFn: FetchLike, url: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetchFn(url, {
|
const res = await fetchFn(url, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
||||||
...init
|
...init
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`)
|
||||||
return res.json() as Promise<T>
|
return res.json() as Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const get = <T>(f: FetchLike, path: string, params?: Dict, init?: RequestInit) =>
|
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) =>
|
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 })
|
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) =>
|
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 })
|
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) =>
|
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 type { FetchLike } from '../core'
|
||||||
import { get } from '../core'
|
import { get, buildUrl } from '../core'
|
||||||
|
|
||||||
export interface UserInfoResponse {
|
export interface UserInfoResponse {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -19,3 +19,29 @@ export const users = {
|
||||||
info: (f: FetchLike, username: string, init?: RequestInit) =>
|
info: (f: FetchLike, username: string, init?: RequestInit) =>
|
||||||
get<UserInfoResponse>(f, `/users/info/${encodeURIComponent(username)}`, undefined, init)
|
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