Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,259 changes: 868 additions & 391 deletions docs/openapi.yaml

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions spx-gui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ Keep import statements in order:
2. Internal libraries: from base to specific, e.g., from `utils` to `models` to `components`
3. Local files: relative paths starting with `./` or `../`

### API String Length Validation

* For API string `minLength` / `maxLength` constraints from `docs/openapi.yaml`, use `getApiStringLength` from
`@/utils/utils` to count Unicode code points. Avoid `string.length`, HTML `maxlength`, or `z.string().max()` for
those code point limits.
* Count the exact string value submitted to the API. If an input is trimmed before submission, trim before counting.
* Exceptions: use byte or source-file-size checks for explicit byte or payload-size limits. For values proven ASCII-only
by a pattern or parser, `string.length` is acceptable.

### Asset URLs

* For widget-safe full asset URLs, use `new URL('...', import.meta.url).href` instead of `import x from './file.ext?url'`.
Expand Down
7 changes: 4 additions & 3 deletions spx-gui/src/apis/admin/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ export type {
CreatedAccountAppToken
} from '@/apis/account/common'

type SortOrder = 'asc' | 'desc'

/** Maximum allowed length for an app token name. */
export const accountAppTokenNameMaxLength = 100
export const accountAppDisplayNameMaxLength = 100
export const accountAppSecretNameMaxLength = 100

type SortOrder = 'asc' | 'desc'

export type ListAccountUsersParams = PaginationParams & {
/** Filter account users by username or display name pattern */
Expand Down
2 changes: 2 additions & 0 deletions spx-gui/src/apis/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { client, Visibility } from './common'

export { Visibility }

export const assetDisplayNameMaxLength = 100

export enum AssetType {
Sprite = 'sprite',
Backdrop = 'backdrop',
Expand Down
156 changes: 113 additions & 43 deletions spx-gui/src/apis/common/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiException, ApiExceptionCode, isQuotaExceededMeta } from './exception'
import { ApiException, ApiExceptionCode, OAuthException, isQuotaExceededMeta, isRetryAfterMeta } from './exception'
import { Client } from './client'

function makeJsonResponse(body: unknown, status: number, headers: Record<string, string> = {}) {
return new Response(JSON.stringify(body), {
status,
headers: {
'Content-Type': 'application/json',
...headers
}
})
}

function makeMovedResponse(canonicalPath: string) {
return new Response(
JSON.stringify({
return makeJsonResponse(
{
code: ApiExceptionCode.errorResourceMoved,
msg: 'Resource moved',
canonical: {
path: canonicalPath
}
}),
{
status: 409,
headers: {
'Content-Type': 'application/json'
}
}
},
409
)
}

function makeQuotaExceededResponse(retryAfter: string) {
return new Response(
JSON.stringify({
return makeJsonResponse(
{
code: ApiExceptionCode.errorQuotaExceeded,
msg: 'Quota exceeded'
}),
},
403,
{
status: 403,
headers: {
'Content-Type': 'application/json',
'Retry-After': retryAfter
}
'Retry-After': retryAfter
}
)
}

function makeRateLimitExceededResponse(retryAfter: string) {
return makeJsonResponse(
{
code: ApiExceptionCode.errorRateLimitExceeded,
msg: 'Rate limit exceeded'
},
429,
{
'Retry-After': retryAfter
}
)
}

function makeOAuthErrorResponse() {
return makeJsonResponse(
{
error: 'invalid_request',
error_description: 'invalid form body'
},
400
)
}

async function expectApiException(promise: Promise<unknown>, code: ApiExceptionCode) {
try {
await promise
} catch (e) {
expect(e).toBeInstanceOf(ApiException)
const exception = e as ApiException
expect(exception).toMatchObject({ code })
return exception
}
throw new Error(`expected API error ${code}`)
}

async function expectOAuthException(promise: Promise<unknown>, error: string) {
try {
await promise
} catch (e) {
expect(e).toBeInstanceOf(OAuthException)
const exception = e as OAuthException
expect(exception).toMatchObject({ error })
return exception
}
throw new Error(`expected OAuth error ${error}`)
}

describe('Client', () => {
let client: Client
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>
Expand All @@ -56,18 +105,10 @@ describe('Client', () => {
it('should surface the moved conflict without retrying', async () => {
fetchMock.mockResolvedValueOnce(makeMovedResponse('/projects/john/demo/views'))

try {
await client.post('/projects/John/demo/views')
throw new Error('expected moved conflict')
} catch (e) {
expect(e).toBeInstanceOf(ApiException)
expect(e).toMatchObject({
code: ApiExceptionCode.errorResourceMoved,
meta: {
path: '/projects/john/demo/views'
}
})
}
const e = await expectApiException(client.post('/projects/John/demo/views'), ApiExceptionCode.errorResourceMoved)
expect(e.meta).toMatchObject({
path: '/projects/john/demo/views'
})

expect(fetchMock).toHaveBeenCalledTimes(1)
expect(new URL((fetchMock.mock.calls[0]![0] as Request).url).pathname).toBe('/projects/John/demo/views')
Expand All @@ -79,19 +120,48 @@ describe('Client', () => {
const retryAfter = 'Wed, 09 Apr 2026 08:00:00 GMT'
fetchMock.mockResolvedValueOnce(makeQuotaExceededResponse(retryAfter))

try {
await client.get('/quota')
throw new Error('expected quota exceeded error')
} catch (e) {
expect(e).toBeInstanceOf(ApiException)
expect(e).toMatchObject({
code: ApiExceptionCode.errorQuotaExceeded
})
expect(isQuotaExceededMeta((e as ApiException).code, (e as ApiException).meta)).toBe(true)
expect((e as ApiException).meta).toMatchObject({
retryAfter: new Date(retryAfter).valueOf()
})
}
const e = await expectApiException(client.get('/quota'), ApiExceptionCode.errorQuotaExceeded)
expect(isQuotaExceededMeta(e.code, e.meta)).toBe(true)
expect(e.meta).toMatchObject({
retryAfter: new Date(retryAfter).valueOf()
})
})
})

describe('retry-after metadata', () => {
it('should parse retry-after metadata for rate limits', async () => {
fetchMock.mockResolvedValueOnce(makeRateLimitExceededResponse('2'))

const e = await expectApiException(client.get('/rate-limited'), ApiExceptionCode.errorRateLimitExceeded)
expect(isRetryAfterMeta(e.code, e.meta)).toBe(true)
expect(e.meta).toMatchObject({
retryAfter: expect.any(Number)
})
})

it('should treat empty retry-after headers as no retry time', async () => {
fetchMock.mockResolvedValueOnce(makeRateLimitExceededResponse(' '))

const e = await expectApiException(client.get('/rate-limited'), ApiExceptionCode.errorRateLimitExceeded)
expect(isRetryAfterMeta(e.code, e.meta)).toBe(true)
expect(e.meta).toMatchObject({
retryAfter: null
})
})

it('should reject retry-after metadata without a retryAfter field', () => {
expect(isRetryAfterMeta(ApiExceptionCode.errorRateLimitExceeded, {})).toBe(false)
})
})

describe('OAuth errors', () => {
it('should parse OAuth error payloads', async () => {
fetchMock.mockResolvedValueOnce(makeOAuthErrorResponse())

const e = await expectOAuthException(client.postForm('/account/oauth/token', {}), 'invalid_request')
expect(e.errorDescription).toBe('invalid form body')
expect(e.errorUri).toBeNull()
expect(e.message).toContain('[invalid_request] invalid form body')
})
})

Expand Down
74 changes: 55 additions & 19 deletions spx-gui/src/apis/common/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,83 @@
import * as Sentry from '@sentry/vue'
import dayjs from 'dayjs'
import { getTimeoutSignal, mergeSignals } from '@/utils/disposable'
import { ApiException, ApiExceptionCode, type MovedResourceCanonical, type QuotaExceededMeta } from './exception'
import {
ApiException,
ApiExceptionCode,
OAuthException,
type MovedResourceCanonical,
type RetryAfterMeta
} from './exception'
import { parseSSE, type SSEEvent } from './sse'

/** Response body when exception encountered for API calling */
export type ApiExceptionPayload = {
/** Code for program comsuming */
/** Code for program consuming */
code: number
/** Message for developer reading */
msg: string
canonical?: MovedResourceCanonical
}

/** Response body when OAuth exception encountered for API calling */
export type OAuthExceptionPayload = {
/** OAuth error code */
error: string
/** Message for developer reading */
error_description?: string
/** URI for human-readable error information */
error_uri?: string
}

function isApiExceptionPayload(body: any): body is ApiExceptionPayload {
return body && typeof body.code === 'number' && typeof body.msg === 'string'
}

function getQuotaExceededMeta(headers: Headers): QuotaExceededMeta {
function isOAuthExceptionPayload(body: any): body is OAuthExceptionPayload {
return (
body &&
typeof body.error === 'string' &&
(body.error_description == null || typeof body.error_description === 'string') &&
(body.error_uri == null || typeof body.error_uri === 'string')
)
}

function getRetryAfterMeta(headers: Headers): RetryAfterMeta {
const retryAfter = headers.get('Retry-After')
let date
if (retryAfter != null) {
const seconds = Number(retryAfter)
date = Number.isFinite(seconds) ? dayjs().add(seconds, 's') : dayjs(retryAfter)
if (retryAfter == null || retryAfter.trim() === '') {
return {
retryAfter: null
}
}

const seconds = Number(retryAfter)
const date = Number.isFinite(seconds) ? dayjs().add(seconds, 's') : dayjs(retryAfter)
return {
retryAfter: date?.isValid() ? date.valueOf() : null
retryAfter: date.isValid() ? date.valueOf() : null
}
}
Comment thread
aofei marked this conversation as resolved.

function getApiExceptionMeta(code: number, resp: Response, payload: ApiExceptionPayload): unknown {
switch (code) {
case ApiExceptionCode.errorQuotaExceeded:
return getQuotaExceededMeta(resp.headers)
case ApiExceptionCode.errorTooManyRequests:
case ApiExceptionCode.errorRateLimitExceeded:
return getRetryAfterMeta(resp.headers)
case ApiExceptionCode.errorResourceMoved:
return payload.canonical ?? null
default:
return null
}
}

async function readErrorPayload(resp: Response): Promise<unknown> {
try {
return await resp.json()
} catch {
return null
}
}

/** TokenProvider provides access token used for the Authorization header */
export type TokenProvider = () => Promise<string | null>

Expand Down Expand Up @@ -139,21 +177,19 @@ export class Client {
const [timeoutSignal, cancelTimeout] = getTimeoutSignal(timeout)
const signal = mergeSignals(timeoutSignal, options?.signal)
const resp = await this.fetchFn(req, { signal }).finally(cancelTimeout)
if (!resp.ok) {
let payload: ApiExceptionPayload | undefined
try {
const body = await resp.json()
if (isApiExceptionPayload(body)) payload = body
} catch {
// ignore
}
if (payload == null) throw new Error(`status ${resp.status} for api call: ${req.url.slice(0, 200)}`)
if (resp.ok) return resp

const payload = await readErrorPayload(resp)
if (isApiExceptionPayload(payload)) {
throw new ApiException(payload.code, payload.msg, {
req,
meta: getApiExceptionMeta(payload.code, resp, payload)
})
}
return resp
if (isOAuthExceptionPayload(payload)) {
throw new OAuthException(payload.error, payload.error_description ?? null, payload.error_uri ?? null, { req })
}
throw new Error(`status ${resp.status} for api call: ${req.url.slice(0, 200)}`)
}

/** Do a JSON request, parsing response body as JSON */
Expand Down
Loading