V13-FE-006: consolidate approved UI and contract hardening
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import { ApiProblem, type ProblemDetails } from './problem'
|
||||
import { ApiProblem, problemDetailsSchema } from './problem'
|
||||
|
||||
export const api = axios.create({ baseURL: '/api', timeout: 15_000 })
|
||||
|
||||
@@ -16,8 +16,8 @@ api.interceptors.request.use(config => {
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
const data = error.response?.data as ProblemDetails | undefined
|
||||
if (data?.status) throw new ApiProblem(data)
|
||||
const parsed = problemDetailsSchema.safeParse(error.response?.data)
|
||||
if (parsed.success) throw new ApiProblem(parsed.data)
|
||||
throw error
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,10 +1,57 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const problemTypeSchema = z.enum([
|
||||
'validation', 'business-rule', 'conflict', 'permission', 'not-found', 'integration', 'system',
|
||||
])
|
||||
|
||||
const validationErrorSchema = z.object({
|
||||
field: z.string().nullable().optional(),
|
||||
rowKey: z.string().nullable().optional(),
|
||||
code: z.string().min(1),
|
||||
message: z.string().min(1),
|
||||
}).strict()
|
||||
|
||||
const problemActionSchema = z.object({ id: z.string().min(1), label: z.string().min(1) }).strict()
|
||||
|
||||
export interface ProblemDetails {
|
||||
type?: string
|
||||
title: string
|
||||
status: number
|
||||
detail?: string
|
||||
traceId?: string
|
||||
correlationId?: string
|
||||
code?: string
|
||||
retryable?: boolean
|
||||
currentVersion?: number | null
|
||||
actions?: Array<{ id: string; label: string }>
|
||||
errors?: Record<string, string[]>
|
||||
validationErrors?: Array<{ field?: string | null; rowKey?: string | null; code: string; message: string }>
|
||||
}
|
||||
|
||||
export const problemDetailsSchema = z.object({
|
||||
type: z.string().optional(),
|
||||
title: z.string().min(1),
|
||||
status: z.number().int().min(400).max(599),
|
||||
detail: z.string().optional(),
|
||||
traceId: z.string().optional(),
|
||||
correlationId: z.string().optional(),
|
||||
code: z.string().min(1).optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
currentVersion: z.number().int().min(1).nullable().optional(),
|
||||
actions: z.array(problemActionSchema).optional(),
|
||||
errors: z.record(z.string(), z.array(z.string())).optional(),
|
||||
validationErrors: z.array(validationErrorSchema).optional(),
|
||||
}).strict()
|
||||
|
||||
export type ProblemInteraction = 'UNAUTHORIZED' | 'FORBIDDEN' | 'CONFLICT' | 'VALIDATION' | 'RETRYABLE' | 'ERROR'
|
||||
|
||||
export function resolveProblemInteraction(problem: Pick<ProblemDetails, 'status' | 'type' | 'retryable'>): ProblemInteraction {
|
||||
if (problem.status === 401) return 'UNAUTHORIZED'
|
||||
if (problem.status === 403 || problem.type === 'permission') return 'FORBIDDEN'
|
||||
if (problem.status === 409 || problem.type === 'conflict') return 'CONFLICT'
|
||||
if (problem.status === 422 || problem.type === 'validation') return 'VALIDATION'
|
||||
if (problem.retryable === true || problem.type === 'integration' || problem.status === 429 || problem.status === 503) return 'RETRYABLE'
|
||||
return 'ERROR'
|
||||
}
|
||||
|
||||
export class ApiProblem extends Error {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { problemDetailsSchema, resolveProblemInteraction } from '../problem'
|
||||
|
||||
describe('ProblemDetails runtime contract', () => {
|
||||
it('accepts a structured API problem', () => {
|
||||
expect(problemDetailsSchema.safeParse({
|
||||
type: 'https://example.test/problems/validation',
|
||||
title: 'Validation failed',
|
||||
status: 422,
|
||||
detail: 'One or more fields are invalid.',
|
||||
traceId: 'trace-1',
|
||||
errors: { name: ['Required'] },
|
||||
}).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects malformed or non-HTTP error payloads', () => {
|
||||
expect(problemDetailsSchema.safeParse({ title: 'Bad', status: 200 }).success).toBe(false)
|
||||
expect(problemDetailsSchema.safeParse({ title: 'Bad', status: 500, unexpected: true }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts KBX-derived discriminators without changing the HTTP boundary', () => {
|
||||
expect(problemDetailsSchema.safeParse({
|
||||
type: 'conflict', title: 'Version conflict', status: 409,
|
||||
correlationId: 'corr-1', currentVersion: 4,
|
||||
actions: [{ id: 'reload', label: '새로 고침' }], retryable: false,
|
||||
}).success).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[401, undefined, 'UNAUTHORIZED'], [403, 'permission', 'FORBIDDEN'],
|
||||
[409, 'conflict', 'CONFLICT'], [422, 'validation', 'VALIDATION'],
|
||||
[503, 'integration', 'RETRYABLE'], [500, 'system', 'ERROR'],
|
||||
] as const)('maps %s to the standard interaction boundary', (status, type, expected) => {
|
||||
expect(resolveProblemInteraction({ status, type, retryable: false })).toBe(expected)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user