V13-FE-006: consolidate approved UI and contract hardening
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s

This commit is contained in:
2026-08-13 02:41:00 +09:00
parent d79edae546
commit 3f293d8aa8
1278 changed files with 14384 additions and 1664 deletions
+3 -3
View File
@@ -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
}
)
+47
View File
@@ -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)
})
})