improve api adapters

This commit is contained in:
Justin Edmund 2025-09-29 23:47:01 -07:00
parent 1298ae1a35
commit cf34092ccc
9 changed files with 94 additions and 126 deletions

View file

@ -7,7 +7,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { PartyAdapter } from '../party.adapter' import { PartyAdapter } from '../party.adapter'
import type { Party } from '../party.adapter' import type { Party } from '$lib/types/api/party'
describe('PartyAdapter', () => { describe('PartyAdapter', () => {
let adapter: PartyAdapter let adapter: PartyAdapter
@ -25,26 +25,26 @@ describe('PartyAdapter', () => {
}, },
job: { job: {
id: 'job-1', id: 'job-1',
name: { en: 'Warrior' }, name: { en: 'Warrior', ja: 'ウォリアー' },
skills: [ skills: [
{ {
id: 'skill-1', id: 'skill-1',
name: { en: 'Rage' }, name: { en: 'Rage', ja: 'レイジ' },
slot: 1 slot: 1
} }
] ]
}, },
raid: { raid: {
id: 'raid-1', id: 'raid-1',
name: { en: 'Proto Bahamut' }, name: { en: 'Proto Bahamut', ja: 'プロトバハムート' },
group: { group: {
id: 'group-1', id: 'group-1',
name: { en: 'Tier 1' } name: { en: 'Tier 1', ja: 'ティア1' }
} }
}, },
gridWeapons: [], weapons: [],
gridSummons: [], summons: [],
gridCharacters: [], characters: [],
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z' updatedAt: '2024-01-01T00:00:00Z'
} }

View file

@ -8,7 +8,6 @@
* @module adapters/base * @module adapters/base
*/ */
import { snakeToCamel, camelToSnake } from '../schemas/transforms'
import { transformResponse, transformRequest } from '../client' import { transformResponse, transformRequest } from '../client'
import type { AdapterOptions, RequestOptions, AdapterError } from './types' import type { AdapterOptions, RequestOptions, AdapterError } from './types'
import { import {
@ -90,10 +89,7 @@ export abstract class BaseAdapter {
* }) * })
* ``` * ```
*/ */
protected async request<T>( protected async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
path: string,
options: RequestOptions = {}
): Promise<T> {
// Build the full URL with query parameters (support both params and query) // Build the full URL with query parameters (support both params and query)
const url = this.buildURL(path, options.query || options.params) const url = this.buildURL(path, options.query || options.params)
@ -264,7 +260,7 @@ export abstract class BaseAdapter {
*/ */
cancelAll(): void { cancelAll(): void {
// Abort all pending requests // Abort all pending requests
this.abortControllers.forEach(controller => controller.abort()) this.abortControllers.forEach((controller) => controller.abort())
this.abortControllers.clear() this.abortControllers.clear()
} }
@ -290,8 +286,9 @@ export abstract class BaseAdapter {
let response: Response let response: Response
if (this.options.timeout > 0) { if (this.options.timeout > 0) {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
const controller = Array.from(this.abortControllers.values()) const controller = Array.from(this.abortControllers.values()).find(
.find(c => c.signal === options.signal) (c) => c.signal === options.signal
)
controller?.abort() controller?.abort()
}, this.options.timeout) }, this.options.timeout)
@ -349,7 +346,6 @@ export abstract class BaseAdapter {
} }
} }
/** /**
* Delays execution for a specified duration * Delays execution for a specified duration
* Used for retry backoff * Used for retry backoff
@ -358,7 +354,7 @@ export abstract class BaseAdapter {
* @returns Promise that resolves after the delay * @returns Promise that resolves after the delay
*/ */
protected delay(ms: number): Promise<void> { protected delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms))
} }
/** /**
@ -425,7 +421,7 @@ export abstract class BaseAdapter {
// Handle arrays by adding multiple params with the same key // Handle arrays by adding multiple params with the same key
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach(item => { value.forEach((item) => {
url.searchParams.append(key, String(item)) url.searchParams.append(key, String(item))
}) })
} else { } else {
@ -465,7 +461,7 @@ export abstract class BaseAdapter {
let hash = 0 let hash = 0
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i) const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char hash = (hash << 5) - hash + char
hash = hash & hash // Convert to 32bit integer hash = hash & hash // Convert to 32bit integer
} }
return hash.toString(36) return hash.toString(36)
@ -487,10 +483,11 @@ export abstract class BaseAdapter {
const errorData = await response.json() const errorData = await response.json()
// Extract error message from various possible formats // Extract error message from various possible formats
message = errorData.message || message =
errorData.error || errorData.message ||
errorData.errors?.[0]?.message || errorData.error ||
response.statusText errorData.errors?.[0]?.message ||
response.statusText
details = errorData details = errorData
} catch { } catch {
@ -552,4 +549,4 @@ export abstract class BaseAdapter {
this.cache.clear() this.cache.clear()
} }
} }
} }

View file

@ -14,7 +14,7 @@ import type { AdapterError } from './types'
* Extends the native Error class with additional properties * Extends the native Error class with additional properties
*/ */
export class ApiError extends Error implements AdapterError { export class ApiError extends Error implements AdapterError {
name: 'AdapterError' = 'AdapterError' name = 'AdapterError' as const
code: string code: string
status: number status: number
details?: any details?: any
@ -173,11 +173,7 @@ export class RateLimitError extends ApiError {
* @param details - Additional error details * @param details - Additional error details
* @returns Appropriate error instance based on status code * @returns Appropriate error instance based on status code
*/ */
export function createErrorFromStatus( export function createErrorFromStatus(status: number, message?: string, details?: any): ApiError {
status: number,
message?: string,
details?: any
): ApiError {
switch (status) { switch (status) {
case 400: case 400:
return new ApiError('BAD_REQUEST', status, message || 'Bad request', details) return new ApiError('BAD_REQUEST', status, message || 'Bad request', details)
@ -190,7 +186,9 @@ export function createErrorFromStatus(
case 404: case 404:
// Pass the message to NotFoundError if provided // Pass the message to NotFoundError if provided
return message ? new ApiError('NOT_FOUND', 404, message, details) : new NotFoundError(undefined, details) return message
? new ApiError('NOT_FOUND', 404, message, details)
: new NotFoundError(undefined, details)
case 409: case 409:
return new ConflictError(message, details) return new ConflictError(message, details)
@ -254,7 +252,11 @@ export function isRetryableError(error: any): boolean {
// Check by error code (handles both ApiError instances and plain objects) // Check by error code (handles both ApiError instances and plain objects)
// Note: NetworkError sets name to 'NetworkError' but still has AdapterError structure // Note: NetworkError sets name to 'NetworkError' but still has AdapterError structure
if (error instanceof ApiError || error?.name === 'AdapterError' || error?.name === 'NetworkError') { if (
error instanceof ApiError ||
error?.name === 'AdapterError' ||
error?.name === 'NetworkError'
) {
const retryableCodes = [ const retryableCodes = [
'NETWORK_ERROR', 'NETWORK_ERROR',
'TIMEOUT', 'TIMEOUT',
@ -321,11 +323,7 @@ export function normalizeError(error: any): AdapterError {
// Generic Error with status // Generic Error with status
if (error?.status) { if (error?.status) {
return createErrorFromStatus( return createErrorFromStatus(error.status, error.message || error.statusText, error).toJSON()
error.status,
error.message || error.statusText,
error
).toJSON()
} }
// Fallback to generic error // Fallback to generic error
@ -349,11 +347,12 @@ export function getErrorMessage(error: any): string {
} }
// Try to get message from various error formats // Try to get message from various error formats
const message = error.message || const message =
error.error || error.message ||
error.errors?.[0]?.message || error.error ||
error.statusText || error.errors?.[0]?.message ||
'An unknown error occurred' error.statusText ||
'An unknown error occurred'
// Make network errors more user-friendly // Make network errors more user-friendly
if (message.includes('NetworkError') || message.includes('Failed to fetch')) { if (message.includes('NetworkError') || message.includes('Failed to fetch')) {

View file

@ -9,62 +9,10 @@
*/ */
import { BaseAdapter } from './base.adapter' import { BaseAdapter } from './base.adapter'
import type { RequestOptions, AdapterOptions, PaginatedResponse } from './types' import type { AdapterOptions, PaginatedResponse } from './types'
import { DEFAULT_ADAPTER_CONFIG } from './config' import { DEFAULT_ADAPTER_CONFIG } from './config'
import type { Party, GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party' import type { Party, GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party'
/**
* Party data structure
*/
export interface Party {
id: string
shortcode: string
name?: string
description?: string
visibility: 'public' | 'private' | 'unlisted'
user: {
id: string
username: string
}
job?: {
id: string
name: Record<string, string>
skills: Array<{
id: string
name: Record<string, string>
slot: number
}>
accessory?: {
id: string
name: Record<string, string>
}
}
raid?: {
id: string
name: Record<string, string>
group?: {
id: string
name: Record<string, string>
}
}
weapons: GridWeapon[]
summons: GridSummon[]
characters: GridCharacter[]
guidebook?: {
id: string
title: string
}
extras?: Record<string, any>
createdAt: string
updatedAt: string
}
// GridWeapon type is imported from types/api/party
// GridSummon type is imported from types/api/party
// GridCharacter type is imported from types/api/party
/** /**
* Parameters for creating a new party * Parameters for creating a new party
*/ */
@ -261,10 +209,7 @@ export class PartyAdapter extends BaseAdapter {
/** /**
* Updates the job for a party * Updates the job for a party
*/ */
async updateJob( async updateJob(shortcode: string, jobId: string): Promise<Party> {
shortcode: string,
jobId: string
): Promise<Party> {
return this.request<Party>(`/parties/${shortcode}/jobs`, { return this.request<Party>(`/parties/${shortcode}/jobs`, {
method: 'PUT', method: 'PUT',
body: { body: {
@ -277,25 +222,45 @@ export class PartyAdapter extends BaseAdapter {
* Updates job skills for a party * Updates job skills for a party
*/ */
async updateJobSkills( async updateJobSkills(
shortcode: string, partyId: string,
skills: Array<{ id: string; slot: number }> skills: Array<{ id: string; slot: number }>
): Promise<Party> { ): Promise<Party> {
return this.request<Party>(`/parties/${shortcode}/job_skills`, { console.log('[updateJobSkills] Input skills array:', skills)
// Convert skills array to Rails expected format
const party: Record<string, string | null> = {}
// Initialize all slots with null
for (let i = 1; i <= 4; i++) {
party[`skill${i}_id`] = null
}
// Set the provided skills
skills.forEach(skill => {
// Rails expects skill1_id, skill2_id, skill3_id, skill4_id
party[`skill${skill.slot + 1}_id`] = skill.id
})
const requestBody = {
party
}
console.log('[updateJobSkills] Sending to server:', {
url: `/parties/${partyId}/job_skills`,
body: requestBody
})
return this.request<Party>(`/parties/${partyId}/job_skills`, {
method: 'PUT', method: 'PUT',
body: { body: requestBody
skills
}
}) })
} }
/** /**
* Removes a job skill from a party * Removes a job skill from a party
*/ */
async removeJobSkill( async removeJobSkill(partyId: string, skillSlot: number): Promise<Party> {
shortcode: string, return this.request<Party>(`/parties/${partyId}/job_skills`, {
skillSlot: number
): Promise<Party> {
return this.request<Party>(`/parties/${shortcode}/job_skills`, {
method: 'DELETE', method: 'DELETE',
body: { body: {
slot: skillSlot slot: skillSlot
@ -310,7 +275,7 @@ export class PartyAdapter extends BaseAdapter {
return this.request<Blob>(`/parties/${shortcode}/preview`, { return this.request<Blob>(`/parties/${shortcode}/preview`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'image/png' Accept: 'image/png'
} }
}) })
} }
@ -377,4 +342,4 @@ export class PartyAdapter extends BaseAdapter {
/** /**
* Default party adapter instance * Default party adapter instance
*/ */
export const partyAdapter = new PartyAdapter(DEFAULT_ADAPTER_CONFIG) export const partyAdapter = new PartyAdapter(DEFAULT_ADAPTER_CONFIG)

View file

@ -42,8 +42,8 @@ export interface InfiniteScrollOptions<T> {
* @example * @example
* ```svelte * ```svelte
* <script> * <script>
* import { createInfiniteScrollResource } from '$lib/api/adapters/resources' * import { createInfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte'
* import { partyAdapter } from '$lib/api/adapters' * import { partyAdapter } from '$lib/api/adapters/party.adapter'
* *
* const resource = createInfiniteScrollResource({ * const resource = createInfiniteScrollResource({
* fetcher: (page) => partyAdapter.list({ page }), * fetcher: (page) => partyAdapter.list({ page }),
@ -243,7 +243,7 @@ export class InfiniteScrollResource<T> {
} finally { } finally {
this.loadingMore = false this.loadingMore = false
if (this.abortController) { if (this.abortController) {
this.abortController = undefined delete this.abortController
} }
} }
} }

View file

@ -7,7 +7,8 @@
* @module adapters/resources/party * @module adapters/resources/party
*/ */
import { PartyAdapter, partyAdapter, type Party, type CreatePartyParams, type UpdatePartyParams } from '../party.adapter' import { PartyAdapter, partyAdapter, type CreatePartyParams, type UpdatePartyParams } from '../party.adapter'
import type { Party } from '$lib/types/api/party'
import type { AdapterError } from '../types' import type { AdapterError } from '../types'
/** /**
@ -48,7 +49,7 @@ interface PartyListState {
* @example * @example
* ```svelte * ```svelte
* <script> * <script>
* import { createPartyResource } from '$lib/api/adapters/resources' * import { createPartyResource } from '$lib/api/adapters/resources/party.resource.svelte'
* *
* const party = createPartyResource() * const party = createPartyResource()
* *
@ -299,7 +300,7 @@ export class PartyResource {
* Updates the job for the current party * Updates the job for the current party
*/ */
async updateJob( async updateJob(
shortcode: string, partyId: string,
jobId: string, jobId: string,
skills?: Array<{ id: string; slot: number }>, skills?: Array<{ id: string; slot: number }>,
accessoryId?: string accessoryId?: string
@ -307,7 +308,16 @@ export class PartyResource {
this.current = { ...this.current, updating: true, error: undefined } this.current = { ...this.current, updating: true, error: undefined }
try { try {
const party = await this.adapter.updateJob(shortcode, jobId, skills, accessoryId) // Update job first
let party = await this.adapter.updateJob(partyId, jobId)
// Update skills if provided
if (skills) {
party = await this.adapter.updateJobSkills(partyId, skills)
}
// TODO: Handle accessory update when API supports it
this.current = { data: party, loading: false, updating: false } this.current = { data: party, loading: false, updating: false }
return party return party
} catch (error: any) { } catch (error: any) {

View file

@ -39,7 +39,7 @@ interface SearchState {
* @example * @example
* ```svelte * ```svelte
* <script> * <script>
* import { createSearchResource } from '$lib/api/adapters/resources' * import { createSearchResource } from '$lib/api/adapters/resources/search.resource.svelte'
* *
* const search = createSearchResource({ * const search = createSearchResource({
* debounceMs: 300, * debounceMs: 300,

View file

@ -359,4 +359,4 @@ export interface ResourceOptions<T> {
/** Dependencies that trigger refetch when changed */ /** Dependencies that trigger refetch when changed */
dependencies?: any[] dependencies?: any[]
} }

View file

@ -321,11 +321,8 @@ const GridCharacterSchema = z.object({
perpetuity: z.boolean().nullish().default(false), perpetuity: z.boolean().nullish().default(false),
// Rings and earring // Rings and earring
ring1: RingSchema, over_mastery: z.array(RingSchema).nullish(),
ring2: RingSchema, aetherial_mastery: RingSchema.nullish(),
ring3: RingSchema,
ring4: RingSchema,
earring: RingSchema,
// Awakening // Awakening
awakening_id: z.string().nullish(), awakening_id: z.string().nullish(),