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

View file

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

View file

@ -14,7 +14,7 @@ import type { AdapterError } from './types'
* Extends the native Error class with additional properties
*/
export class ApiError extends Error implements AdapterError {
name: 'AdapterError' = 'AdapterError'
name = 'AdapterError' as const
code: string
status: number
details?: any
@ -173,11 +173,7 @@ export class RateLimitError extends ApiError {
* @param details - Additional error details
* @returns Appropriate error instance based on status code
*/
export function createErrorFromStatus(
status: number,
message?: string,
details?: any
): ApiError {
export function createErrorFromStatus(status: number, message?: string, details?: any): ApiError {
switch (status) {
case 400:
return new ApiError('BAD_REQUEST', status, message || 'Bad request', details)
@ -190,7 +186,9 @@ export function createErrorFromStatus(
case 404:
// 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:
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)
// 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 = [
'NETWORK_ERROR',
'TIMEOUT',
@ -321,11 +323,7 @@ export function normalizeError(error: any): AdapterError {
// Generic Error with status
if (error?.status) {
return createErrorFromStatus(
error.status,
error.message || error.statusText,
error
).toJSON()
return createErrorFromStatus(error.status, error.message || error.statusText, error).toJSON()
}
// Fallback to generic error
@ -349,11 +347,12 @@ export function getErrorMessage(error: any): string {
}
// Try to get message from various error formats
const message = error.message ||
error.error ||
error.errors?.[0]?.message ||
error.statusText ||
'An unknown error occurred'
const message =
error.message ||
error.error ||
error.errors?.[0]?.message ||
error.statusText ||
'An unknown error occurred'
// Make network errors more user-friendly
if (message.includes('NetworkError') || message.includes('Failed to fetch')) {

View file

@ -9,62 +9,10 @@
*/
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 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
*/
@ -261,10 +209,7 @@ export class PartyAdapter extends BaseAdapter {
/**
* Updates the job for a party
*/
async updateJob(
shortcode: string,
jobId: string
): Promise<Party> {
async updateJob(shortcode: string, jobId: string): Promise<Party> {
return this.request<Party>(`/parties/${shortcode}/jobs`, {
method: 'PUT',
body: {
@ -277,25 +222,45 @@ export class PartyAdapter extends BaseAdapter {
* Updates job skills for a party
*/
async updateJobSkills(
shortcode: string,
partyId: string,
skills: Array<{ id: string; slot: number }>
): 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',
body: {
skills
}
body: requestBody
})
}
/**
* Removes a job skill from a party
*/
async removeJobSkill(
shortcode: string,
skillSlot: number
): Promise<Party> {
return this.request<Party>(`/parties/${shortcode}/job_skills`, {
async removeJobSkill(partyId: string, skillSlot: number): Promise<Party> {
return this.request<Party>(`/parties/${partyId}/job_skills`, {
method: 'DELETE',
body: {
slot: skillSlot
@ -310,7 +275,7 @@ export class PartyAdapter extends BaseAdapter {
return this.request<Blob>(`/parties/${shortcode}/preview`, {
method: 'GET',
headers: {
'Accept': 'image/png'
Accept: 'image/png'
}
})
}
@ -377,4 +342,4 @@ export class PartyAdapter extends BaseAdapter {
/**
* 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
* ```svelte
* <script>
* import { createInfiniteScrollResource } from '$lib/api/adapters/resources'
* import { partyAdapter } from '$lib/api/adapters'
* import { createInfiniteScrollResource } from '$lib/api/adapters/resources/infiniteScroll.resource.svelte'
* import { partyAdapter } from '$lib/api/adapters/party.adapter'
*
* const resource = createInfiniteScrollResource({
* fetcher: (page) => partyAdapter.list({ page }),
@ -243,7 +243,7 @@ export class InfiniteScrollResource<T> {
} finally {
this.loadingMore = false
if (this.abortController) {
this.abortController = undefined
delete this.abortController
}
}
}

View file

@ -7,7 +7,8 @@
* @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'
/**
@ -48,7 +49,7 @@ interface PartyListState {
* @example
* ```svelte
* <script>
* import { createPartyResource } from '$lib/api/adapters/resources'
* import { createPartyResource } from '$lib/api/adapters/resources/party.resource.svelte'
*
* const party = createPartyResource()
*
@ -299,7 +300,7 @@ export class PartyResource {
* Updates the job for the current party
*/
async updateJob(
shortcode: string,
partyId: string,
jobId: string,
skills?: Array<{ id: string; slot: number }>,
accessoryId?: string
@ -307,7 +308,16 @@ export class PartyResource {
this.current = { ...this.current, updating: true, error: undefined }
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 }
return party
} catch (error: any) {

View file

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

View file

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

View file

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