Flesh out API layer

This commit is contained in:
Justin Edmund 2025-09-11 03:53:43 -07:00
parent fdc57c45f4
commit cbd92e2d52
6 changed files with 1033 additions and 32 deletions

View file

@ -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)
}

View file

@ -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 })

View file

@ -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
}

View file

@ -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
}
}

View 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
}

View 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
}