From 371a8b28d6b6603ff43b81b4b90697ca968a28be Mon Sep 17 00:00:00 2001 From: v-byte-cpu <65545655+v-byte-cpu@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:40:25 +0400 Subject: [PATCH] feat(api): use problem details for error responses Replace API error responses with RFC 9457-style Problem Details and keep DomainError as the normalized UI/service error contract. API error responses now use application/problem+json with /problem/* type values, title/status/detail fields, and structured validation issues instead of the previous DomainError-shaped JSON payload. --- api/mock-server/src/app.test.ts | 57 +++- api/mock-server/src/app.ts | 4 +- .../generated/clear-web-api/contract/index.ts | 2 +- .../clear-web-api/contract/types.gen.ts | 267 +++++++++--------- .../clear-web-api/contract/zod.gen.ts | 55 ++-- api/mock-server/src/lib/errors.ts | 100 +++++-- api/mock-server/src/lib/honoMockRuntime.ts | 32 ++- api/openapi/openapi.yaml | 4 +- api/openapi/shared/components.yaml | 162 ++++++----- .../references/error-handling.md | 35 ++- ui/src/platform/mock/mockDomainResult.ts | 8 +- .../services/decks/web/deckService.test.ts | 4 +- ui/src/shared/errors/index.test.ts | 91 +++++- ui/src/shared/errors/index.ts | 135 +++++---- ui/src/shared/errors/translation.test.ts | 25 +- ui/src/shared/errors/translation.ts | 18 +- .../shared/services/api/error-mapping.test.ts | 103 ++++++- ui/src/shared/services/api/error-mapping.ts | 92 +++++- .../services/api/generated/clear-api/index.ts | 2 +- .../api/generated/clear-api/types.gen.ts | 267 +++++++++--------- .../api/generated/clear-api/zod.gen.ts | 55 ++-- 21 files changed, 987 insertions(+), 531 deletions(-) diff --git a/api/mock-server/src/app.test.ts b/api/mock-server/src/app.test.ts index a065c86..d39a806 100644 --- a/api/mock-server/src/app.test.ts +++ b/api/mock-server/src/app.test.ts @@ -4,6 +4,11 @@ import { newMockApiApp } from './app.ts' const json = async (response: Response) => response.json() as Promise +const expectProblemResponse = (response: Response, status: number) => { + expect(response.status).toBe(status) + expect(response.headers.get('content-type')).toContain('application/problem+json') +} + describe('mock api app', () => { it('creates counter ids and exposes them through the workspace list', async () => { const app = await newMockApiApp() @@ -66,7 +71,7 @@ describe('mock api app', () => { }), ) - expect(response.status).toBe(422) + expectProblemResponse(response, 422) await expect(json(response)).resolves.toMatchObject({ issues: [ { @@ -79,7 +84,49 @@ describe('mock api app', () => { }, ], retryable: false, - type: 'validation', + status: 422, + title: 'Validation Failed', + type: '/problems/validation', + }) + }) + + it('returns problem details for malformed request bodies', async () => { + const app = await newMockApiApp() + + const response = await app.fetch( + new Request('http://localhost/api/v1/workspaces', { + body: '{', + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + }), + ) + + expectProblemResponse(response, 400) + await expect(json(response)).resolves.toMatchObject({ + detail: 'Request body must be valid JSON.', + retryable: false, + status: 400, + title: 'Bad Request', + type: '/problems/bad-request', + }) + }) + + it('returns problem details for unknown routes', async () => { + const app = await newMockApiApp() + + const response = await app.fetch(new Request('http://localhost/api/v1/not-real')) + + expectProblemResponse(response, 404) + await expect(json(response)).resolves.toMatchObject({ + detail: 'route request was not found', + entity: 'route', + entityId: 'request', + retryable: false, + status: 404, + title: 'Not Found', + type: '/problems/not-found', }) }) @@ -165,7 +212,7 @@ describe('mock api app', () => { }), ) - expect(response.status).toBe(422) + expectProblemResponse(response, 422) await expect(json(response)).resolves.toMatchObject({ issues: [ { @@ -174,7 +221,9 @@ describe('mock api app', () => { }, ], retryable: false, - type: 'validation', + status: 422, + title: 'Validation Failed', + type: '/problems/validation', }) }) diff --git a/api/mock-server/src/app.ts b/api/mock-server/src/app.ts index cdc1033..16e32dc 100644 --- a/api/mock-server/src/app.ts +++ b/api/mock-server/src/app.ts @@ -12,7 +12,7 @@ import { generatedMockRuntime as adminRuntime } from './generated/mock-admin/moc import { generatedMockRuntime as clearWebApiRuntime } from './generated/clear-web-api/mock-runtime.ts' import { notFound } from './lib/errors.ts' import { - mockJsonResponse, + mockProblemResponse, registerGeneratedMockRoutes, } from './lib/honoMockRuntime.ts' @@ -42,7 +42,7 @@ export const newMockApiApp = async ({ app.notFound(() => { const routeError = notFound('route', 'request') - return mockJsonResponse(routeError.body, routeError.status) + return mockProblemResponse(routeError.body, routeError.status) }) return app diff --git a/api/mock-server/src/generated/clear-web-api/contract/index.ts b/api/mock-server/src/generated/clear-web-api/contract/index.ts index 10d1685..bd1905e 100644 --- a/api/mock-server/src/generated/clear-web-api/contract/index.ts +++ b/api/mock-server/src/generated/clear-web-api/contract/index.ts @@ -1,3 +1,3 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { ActiveWorkspace, ActiveWorkspace2, BasicNote, BasicNoteDraft, BasicNoteEditor, BasicReviewCard, Bootstrap, BootstrapBootstrapResult, BootstrapData, BootstrapError, BootstrapErrors, BootstrapResponse, BootstrapResponses, BootstrapResult, CardId, ClientOptions, ClozeNote, ClozeNoteCard, ClozeNoteDraft, ClozeNoteEditor, ClozeReviewCard, ComponentsCardId, ComponentsDeckId, ComponentsDeckSortField, ComponentsDomainError, ComponentsFolderId, ComponentsFolderSortField, ComponentsItemId, ComponentsNoteId, ComponentsNoteSortField, ComponentsReviewId, ComponentsSortDirection, ComponentsWorkspaceId, CreateDeckData, CreateDeckError, CreateDeckErrors, CreateDeckResponse, CreateDeckResponses, CreateFolderData, CreateFolderError, CreateFolderErrors, CreateFolderResponse, CreateFolderResponses, CreateNoteData, CreateNoteError, CreateNoteErrors, CreateNoteResponse, CreateNoteResponses, CreateWorkspaceData, CreateWorkspaceError, CreateWorkspaceErrors, CreateWorkspaceResponse, CreateWorkspaceResponses, DateTime, Deck, DeckById, DeckDraft, DeckId, DeckNotes, DeckReviews, Decks, DecksDeck, DeckSearchResult, DeckSearchResultGroup, DeckSearchScope, DeckSortField, DeckSortField2, DeleteDeckData, DeleteDeckError, DeleteDeckErrors, DeleteDeckResponse, DeleteDeckResponses, DeleteFolderData, DeleteFolderError, DeleteFolderErrors, DeleteFolderResponse, DeleteFolderResponses, DeleteNoteData, DeleteNoteError, DeleteNoteErrors, DeleteNoteResponse, DeleteNoteResponses, DeleteTrashItemData, DeleteTrashItemError, DeleteTrashItemErrors, DeleteTrashItemResponse, DeleteTrashItemResponses, DeleteWorkspaceData, DeleteWorkspaceError, DeleteWorkspaceErrors, DeleteWorkspaceResponse, DeleteWorkspaceResponses, DeleteWorkspaceResult, DomainError, DueReviewSession, DueReviewSessionStatus, EmptyTrashData, EmptyTrashError, EmptyTrashErrors, EmptyTrashResponse, EmptyTrashResponses, Folder, FolderById, FolderDecks, FolderDraft, FolderFolders, FolderId, FolderPath, FolderPath2, Folders, FolderSearchResult, FolderSearchResultGroup, FolderSearchScope, FoldersFolder, FolderSortField, FolderSortField2, GetActiveWorkspaceData, GetActiveWorkspaceError, GetActiveWorkspaceErrors, GetActiveWorkspaceResponse, GetActiveWorkspaceResponses, GetDeckData, GetDeckError, GetDeckErrors, GetDeckResponse, GetDeckResponses, GetDefaultSettingsData, GetDefaultSettingsError, GetDefaultSettingsErrors, GetDefaultSettingsResponse, GetDefaultSettingsResponses, GetFolderData, GetFolderError, GetFolderErrors, GetFolderPathData, GetFolderPathError, GetFolderPathErrors, GetFolderPathResponse, GetFolderPathResponses, GetFolderResponse, GetFolderResponses, GetNoteData, GetNoteError, GetNoteErrors, GetNoteResponse, GetNoteResponses, GetReviewSessionData, GetReviewSessionError, GetReviewSessionErrors, GetReviewSessionResponse, GetReviewSessionResponses, GetSettingsData, GetSettingsError, GetSettingsErrors, GetSettingsResponse, GetSettingsResponses, GetTrashData, GetTrashError, GetTrashErrors, GetTrashResponse, GetTrashResponses, GetWorkspaceData, GetWorkspaceError, GetWorkspaceErrors, GetWorkspaceResponse, GetWorkspaceResponses, GradeReviewCardRequest, GradeReviewSessionCard, GradeReviewSessionCardData, GradeReviewSessionCardError, GradeReviewSessionCardErrors, GradeReviewSessionCardResponse, GradeReviewSessionCardResponses, Id, ItemId, ListFolderDecksData, ListFolderDecksError, ListFolderDecksErrors, ListFolderDecksResponse, ListFolderDecksResponses, ListFolderFoldersData, ListFolderFoldersError, ListFolderFoldersErrors, ListFolderFoldersResponse, ListFolderFoldersResponses, ListNotesByDeckData, ListNotesByDeckError, ListNotesByDeckErrors, ListNotesByDeckResponse, ListNotesByDeckResponses, ListWorkspaceDecksData, ListWorkspaceDecksError, ListWorkspaceDecksErrors, ListWorkspaceDecksResponse, ListWorkspaceDecksResponses, ListWorkspaceFoldersData, ListWorkspaceFoldersError, ListWorkspaceFoldersErrors, ListWorkspaceFoldersResponse, ListWorkspaceFoldersResponses, ListWorkspacesData, ListWorkspacesError, ListWorkspacesErrors, ListWorkspacesResponse, ListWorkspacesResponses, MessageDomainError, NoteById, NoteDetail, NoteDraft, NoteId, NoteKind, NoteListItem, NoteRef, Notes, NoteSearchResult, NoteSearchResultGroup, NotesNoteDetail, NoteSortField, NoteSortField2, NoteStatus, PracticeReviewSession, ResetSettings, ResetSettingsData, ResetSettingsError, ResetSettingsErrors, ResetSettingsResponse, ResetSettingsResponses, RestoreTrashItem, RestoreTrashItemData, RestoreTrashItemError, RestoreTrashItemErrors, RestoreTrashItemResponse, RestoreTrashItemResponses, ReviewById, ReviewCard, ReviewCardOrNull, ReviewGrade, ReviewId, ReviewReviewCard, ReviewReviewSession, ReviewSession, ReviewStartResult, ReviewUnavailable, ReviewUnavailableReason, RuntimeFormFactor, RuntimeKind, RuntimeProfile, Search, SearchContentData, SearchContentError, SearchContentErrors, SearchContentResponse, SearchContentResponses, SearchRequest, SearchResultGroup, SearchResultLocationPath, SearchScope, SearchSearchResultGroup, SetActiveWorkspaceData, SetActiveWorkspaceError, SetActiveWorkspaceErrors, SetActiveWorkspaceRequest, SetActiveWorkspaceResponse, SetActiveWorkspaceResponses, Settings, Settings2, SettingsDefaults, SettingsNewCardsOrder, SettingsSettings, SortDirection, SortDirection2, StartReviewSessionData, StartReviewSessionError, StartReviewSessionErrors, StartReviewSessionResponse, StartReviewSessionResponses, Trash, TrashItem, TrashItemById, TrashKind, TrashState, TrashTrashState, UpdateDeckData, UpdateDeckError, UpdateDeckErrors, UpdateDeckResponse, UpdateDeckResponses, UpdateFolderData, UpdateFolderError, UpdateFolderErrors, UpdateFolderResponse, UpdateFolderResponses, UpdateNoteData, UpdateNoteError, UpdateNoteErrors, UpdateNoteResponse, UpdateNoteResponses, UpdateSettingsData, UpdateSettingsError, UpdateSettingsErrors, UpdateSettingsResponse, UpdateSettingsResponses, UpdateWorkspaceData, UpdateWorkspaceError, UpdateWorkspaceErrors, UpdateWorkspaceResponse, UpdateWorkspaceResponses, ValidationDomainError, ValidationIssue, VisualIconName, Workspace, WorkspaceById, WorkspaceDecks, WorkspaceDraft, WorkspaceFolders, WorkspaceId, WorkspaceListResult, Workspaces, WorkspacesActiveWorkspace, WorkspaceSearchScope, WorkspacesWorkspace } from './types.gen.ts'; +export type { ActiveWorkspace, ActiveWorkspace2, BasicNote, BasicNoteDraft, BasicNoteEditor, BasicReviewCard, Bootstrap, BootstrapBootstrapResult, BootstrapData, BootstrapError, BootstrapErrors, BootstrapResponse, BootstrapResponses, BootstrapResult, CardId, ClientOptions, ClozeNote, ClozeNoteCard, ClozeNoteDraft, ClozeNoteEditor, ClozeReviewCard, ComponentsCardId, ComponentsDeckId, ComponentsDeckSortField, ComponentsFolderId, ComponentsFolderSortField, ComponentsItemId, ComponentsNoteId, ComponentsNoteSortField, ComponentsProblemDetails, ComponentsReviewId, ComponentsSortDirection, ComponentsWorkspaceId, CreateDeckData, CreateDeckError, CreateDeckErrors, CreateDeckResponse, CreateDeckResponses, CreateFolderData, CreateFolderError, CreateFolderErrors, CreateFolderResponse, CreateFolderResponses, CreateNoteData, CreateNoteError, CreateNoteErrors, CreateNoteResponse, CreateNoteResponses, CreateWorkspaceData, CreateWorkspaceError, CreateWorkspaceErrors, CreateWorkspaceResponse, CreateWorkspaceResponses, DateTime, Deck, DeckById, DeckDraft, DeckId, DeckNotes, DeckReviews, Decks, DecksDeck, DeckSearchResult, DeckSearchResultGroup, DeckSearchScope, DeckSortField, DeckSortField2, DeleteDeckData, DeleteDeckError, DeleteDeckErrors, DeleteDeckResponse, DeleteDeckResponses, DeleteFolderData, DeleteFolderError, DeleteFolderErrors, DeleteFolderResponse, DeleteFolderResponses, DeleteNoteData, DeleteNoteError, DeleteNoteErrors, DeleteNoteResponse, DeleteNoteResponses, DeleteTrashItemData, DeleteTrashItemError, DeleteTrashItemErrors, DeleteTrashItemResponse, DeleteTrashItemResponses, DeleteWorkspaceData, DeleteWorkspaceError, DeleteWorkspaceErrors, DeleteWorkspaceResponse, DeleteWorkspaceResponses, DeleteWorkspaceResult, DueReviewSession, DueReviewSessionStatus, EmptyTrashData, EmptyTrashError, EmptyTrashErrors, EmptyTrashResponse, EmptyTrashResponses, Folder, FolderById, FolderDecks, FolderDraft, FolderFolders, FolderId, FolderPath, FolderPath2, Folders, FolderSearchResult, FolderSearchResultGroup, FolderSearchScope, FoldersFolder, FolderSortField, FolderSortField2, GetActiveWorkspaceData, GetActiveWorkspaceError, GetActiveWorkspaceErrors, GetActiveWorkspaceResponse, GetActiveWorkspaceResponses, GetDeckData, GetDeckError, GetDeckErrors, GetDeckResponse, GetDeckResponses, GetDefaultSettingsData, GetDefaultSettingsError, GetDefaultSettingsErrors, GetDefaultSettingsResponse, GetDefaultSettingsResponses, GetFolderData, GetFolderError, GetFolderErrors, GetFolderPathData, GetFolderPathError, GetFolderPathErrors, GetFolderPathResponse, GetFolderPathResponses, GetFolderResponse, GetFolderResponses, GetNoteData, GetNoteError, GetNoteErrors, GetNoteResponse, GetNoteResponses, GetReviewSessionData, GetReviewSessionError, GetReviewSessionErrors, GetReviewSessionResponse, GetReviewSessionResponses, GetSettingsData, GetSettingsError, GetSettingsErrors, GetSettingsResponse, GetSettingsResponses, GetTrashData, GetTrashError, GetTrashErrors, GetTrashResponse, GetTrashResponses, GetWorkspaceData, GetWorkspaceError, GetWorkspaceErrors, GetWorkspaceResponse, GetWorkspaceResponses, GradeReviewCardRequest, GradeReviewSessionCard, GradeReviewSessionCardData, GradeReviewSessionCardError, GradeReviewSessionCardErrors, GradeReviewSessionCardResponse, GradeReviewSessionCardResponses, Id, ItemId, ListFolderDecksData, ListFolderDecksError, ListFolderDecksErrors, ListFolderDecksResponse, ListFolderDecksResponses, ListFolderFoldersData, ListFolderFoldersError, ListFolderFoldersErrors, ListFolderFoldersResponse, ListFolderFoldersResponses, ListNotesByDeckData, ListNotesByDeckError, ListNotesByDeckErrors, ListNotesByDeckResponse, ListNotesByDeckResponses, ListWorkspaceDecksData, ListWorkspaceDecksError, ListWorkspaceDecksErrors, ListWorkspaceDecksResponse, ListWorkspaceDecksResponses, ListWorkspaceFoldersData, ListWorkspaceFoldersError, ListWorkspaceFoldersErrors, ListWorkspaceFoldersResponse, ListWorkspaceFoldersResponses, ListWorkspacesData, ListWorkspacesError, ListWorkspacesErrors, ListWorkspacesResponse, ListWorkspacesResponses, MessageProblemDetails, NoteById, NoteDetail, NoteDraft, NoteId, NoteKind, NoteListItem, NoteRef, Notes, NoteSearchResult, NoteSearchResultGroup, NotesNoteDetail, NoteSortField, NoteSortField2, NoteStatus, PracticeReviewSession, ProblemDetails, ResetSettings, ResetSettingsData, ResetSettingsError, ResetSettingsErrors, ResetSettingsResponse, ResetSettingsResponses, RestoreTrashItem, RestoreTrashItemData, RestoreTrashItemError, RestoreTrashItemErrors, RestoreTrashItemResponse, RestoreTrashItemResponses, ReviewById, ReviewCard, ReviewCardOrNull, ReviewGrade, ReviewId, ReviewReviewCard, ReviewReviewSession, ReviewSession, ReviewStartResult, ReviewUnavailable, ReviewUnavailableReason, RuntimeFormFactor, RuntimeKind, RuntimeProfile, Search, SearchContentData, SearchContentError, SearchContentErrors, SearchContentResponse, SearchContentResponses, SearchRequest, SearchResultGroup, SearchResultLocationPath, SearchScope, SearchSearchResultGroup, SetActiveWorkspaceData, SetActiveWorkspaceError, SetActiveWorkspaceErrors, SetActiveWorkspaceRequest, SetActiveWorkspaceResponse, SetActiveWorkspaceResponses, Settings, Settings2, SettingsDefaults, SettingsNewCardsOrder, SettingsSettings, SortDirection, SortDirection2, StartReviewSessionData, StartReviewSessionError, StartReviewSessionErrors, StartReviewSessionResponse, StartReviewSessionResponses, Trash, TrashItem, TrashItemById, TrashKind, TrashState, TrashTrashState, UpdateDeckData, UpdateDeckError, UpdateDeckErrors, UpdateDeckResponse, UpdateDeckResponses, UpdateFolderData, UpdateFolderError, UpdateFolderErrors, UpdateFolderResponse, UpdateFolderResponses, UpdateNoteData, UpdateNoteError, UpdateNoteErrors, UpdateNoteResponse, UpdateNoteResponses, UpdateSettingsData, UpdateSettingsError, UpdateSettingsErrors, UpdateSettingsResponse, UpdateSettingsResponses, UpdateWorkspaceData, UpdateWorkspaceError, UpdateWorkspaceErrors, UpdateWorkspaceResponse, UpdateWorkspaceResponses, ValidationIssue, ValidationProblemDetails, VisualIconName, Workspace, WorkspaceById, WorkspaceDecks, WorkspaceDraft, WorkspaceFolders, WorkspaceId, WorkspaceListResult, Workspaces, WorkspacesActiveWorkspace, WorkspaceSearchScope, WorkspacesWorkspace } from './types.gen.ts'; diff --git a/api/mock-server/src/generated/clear-web-api/contract/types.gen.ts b/api/mock-server/src/generated/clear-web-api/contract/types.gen.ts index 740a679..d74eec6 100644 --- a/api/mock-server/src/generated/clear-web-api/contract/types.gen.ts +++ b/api/mock-server/src/generated/clear-web-api/contract/types.gen.ts @@ -10,12 +10,12 @@ export type BootstrapResult = BootstrapBootstrapResult; export type Deck = DecksDeck; -export type DomainError = ComponentsDomainError; - export type Folder = FoldersFolder; export type NoteDetail = NotesNoteDetail; +export type ProblemDetails = ComponentsProblemDetails; + export type ReviewCard = ReviewReviewCard; export type ReviewSession = ReviewReviewSession; @@ -449,19 +449,15 @@ export type DateTime = string; export type DeckSortField = 'dueToday' | 'title' | 'updated'; -export type ComponentsDomainError = ({ - type: 'validation'; -} & ValidationDomainError) | ({ - type: 'conflict' | 'forbidden' | 'not_found' | 'offline' | 'timeout' | 'unauthorized' | 'unexpected' | 'unavailable'; -} & MessageDomainError); - export type FolderSortField = 'title' | 'updated'; export type Id = string; -export type MessageDomainError = { - type: 'conflict' | 'forbidden' | 'not_found' | 'offline' | 'timeout' | 'unauthorized' | 'unexpected' | 'unavailable'; - message: string; +export type MessageProblemDetails = { + type: '/problems/bad-request' | '/problems/conflict' | '/problems/forbidden' | '/problems/not-found' | '/problems/timeout' | '/problems/unauthorized' | '/problems/unexpected' | '/problems/unavailable'; + title: string; + status: number; + detail?: string; retryable: boolean; entity?: string; entityId?: string; @@ -469,13 +465,13 @@ export type MessageDomainError = { export type NoteSortField = 'title' | 'updated'; -export type SortDirection = 'asc' | 'desc'; +export type ComponentsProblemDetails = ({ + type: '/problems/validation'; +} & ValidationProblemDetails) | ({ + type: '/problems/bad-request' | '/problems/conflict' | '/problems/forbidden' | '/problems/not-found' | '/problems/timeout' | '/problems/unauthorized' | '/problems/unexpected' | '/problems/unavailable'; +} & MessageProblemDetails); -export type ValidationDomainError = { - type: 'validation'; - issues: Array; - retryable: false; -}; +export type SortDirection = 'asc' | 'desc'; export type ValidationIssue = { path?: Array; @@ -485,6 +481,15 @@ export type ValidationIssue = { }; }; +export type ValidationProblemDetails = { + type: '/problems/validation'; + title: string; + status: 422; + detail?: string; + issues: Array; + retryable: false; +}; + /** * Lucide icon name used by the UI visual picker. */ @@ -611,15 +616,15 @@ export type BootstrapErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; /** * The service is temporarily unavailable. */ - 503: ComponentsDomainError; + 503: ComponentsProblemDetails; }; export type BootstrapError = BootstrapErrors[keyof BootstrapErrors]; @@ -644,11 +649,11 @@ export type ListWorkspacesErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ListWorkspacesError = ListWorkspacesErrors[keyof ListWorkspacesErrors]; @@ -673,19 +678,19 @@ export type CreateWorkspaceErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type CreateWorkspaceError = CreateWorkspaceErrors[keyof CreateWorkspaceErrors]; @@ -710,11 +715,11 @@ export type GetActiveWorkspaceErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetActiveWorkspaceError = GetActiveWorkspaceErrors[keyof GetActiveWorkspaceErrors]; @@ -739,19 +744,19 @@ export type SetActiveWorkspaceErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type SetActiveWorkspaceError = SetActiveWorkspaceErrors[keyof SetActiveWorkspaceErrors]; @@ -781,15 +786,15 @@ export type DeleteWorkspaceErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type DeleteWorkspaceError = DeleteWorkspaceErrors[keyof DeleteWorkspaceErrors]; @@ -819,11 +824,11 @@ export type GetWorkspaceErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetWorkspaceError = GetWorkspaceErrors[keyof GetWorkspaceErrors]; @@ -853,23 +858,23 @@ export type UpdateWorkspaceErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type UpdateWorkspaceError = UpdateWorkspaceErrors[keyof UpdateWorkspaceErrors]; @@ -908,11 +913,11 @@ export type ListWorkspaceFoldersErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ListWorkspaceFoldersError = ListWorkspaceFoldersErrors[keyof ListWorkspaceFoldersErrors]; @@ -951,11 +956,11 @@ export type ListWorkspaceDecksErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ListWorkspaceDecksError = ListWorkspaceDecksErrors[keyof ListWorkspaceDecksErrors]; @@ -980,23 +985,23 @@ export type CreateFolderErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type CreateFolderError = CreateFolderErrors[keyof CreateFolderErrors]; @@ -1026,15 +1031,15 @@ export type DeleteFolderErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type DeleteFolderError = DeleteFolderErrors[keyof DeleteFolderErrors]; @@ -1064,11 +1069,11 @@ export type GetFolderErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetFolderError = GetFolderErrors[keyof GetFolderErrors]; @@ -1098,23 +1103,23 @@ export type UpdateFolderErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type UpdateFolderError = UpdateFolderErrors[keyof UpdateFolderErrors]; @@ -1153,11 +1158,11 @@ export type ListFolderFoldersErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ListFolderFoldersError = ListFolderFoldersErrors[keyof ListFolderFoldersErrors]; @@ -1196,11 +1201,11 @@ export type ListFolderDecksErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ListFolderDecksError = ListFolderDecksErrors[keyof ListFolderDecksErrors]; @@ -1230,11 +1235,11 @@ export type GetFolderPathErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetFolderPathError = GetFolderPathErrors[keyof GetFolderPathErrors]; @@ -1259,23 +1264,23 @@ export type CreateDeckErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type CreateDeckError = CreateDeckErrors[keyof CreateDeckErrors]; @@ -1305,15 +1310,15 @@ export type DeleteDeckErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type DeleteDeckError = DeleteDeckErrors[keyof DeleteDeckErrors]; @@ -1343,11 +1348,11 @@ export type GetDeckErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetDeckError = GetDeckErrors[keyof GetDeckErrors]; @@ -1377,23 +1382,23 @@ export type UpdateDeckErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type UpdateDeckError = UpdateDeckErrors[keyof UpdateDeckErrors]; @@ -1432,11 +1437,11 @@ export type ListNotesByDeckErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ListNotesByDeckError = ListNotesByDeckErrors[keyof ListNotesByDeckErrors]; @@ -1466,11 +1471,11 @@ export type StartReviewSessionErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type StartReviewSessionError = StartReviewSessionErrors[keyof StartReviewSessionErrors]; @@ -1500,11 +1505,11 @@ export type GetReviewSessionErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetReviewSessionError = GetReviewSessionErrors[keyof GetReviewSessionErrors]; @@ -1529,23 +1534,23 @@ export type CreateNoteErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type CreateNoteError = CreateNoteErrors[keyof CreateNoteErrors]; @@ -1575,15 +1580,15 @@ export type DeleteNoteErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type DeleteNoteError = DeleteNoteErrors[keyof DeleteNoteErrors]; @@ -1613,11 +1618,11 @@ export type GetNoteErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetNoteError = GetNoteErrors[keyof GetNoteErrors]; @@ -1647,23 +1652,23 @@ export type UpdateNoteErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type UpdateNoteError = UpdateNoteErrors[keyof UpdateNoteErrors]; @@ -1697,23 +1702,23 @@ export type GradeReviewSessionCardErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GradeReviewSessionCardError = GradeReviewSessionCardErrors[keyof GradeReviewSessionCardErrors]; @@ -1738,11 +1743,11 @@ export type GetSettingsErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetSettingsError = GetSettingsErrors[keyof GetSettingsErrors]; @@ -1767,15 +1772,15 @@ export type UpdateSettingsErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type UpdateSettingsError = UpdateSettingsErrors[keyof UpdateSettingsErrors]; @@ -1800,11 +1805,11 @@ export type GetDefaultSettingsErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetDefaultSettingsError = GetDefaultSettingsErrors[keyof GetDefaultSettingsErrors]; @@ -1829,11 +1834,11 @@ export type ResetSettingsErrors = { /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ResetSettingsError = ResetSettingsErrors[keyof ResetSettingsErrors]; @@ -1858,11 +1863,11 @@ export type EmptyTrashErrors = { /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type EmptyTrashError = EmptyTrashErrors[keyof EmptyTrashErrors]; @@ -1887,11 +1892,11 @@ export type GetTrashErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetTrashError = GetTrashErrors[keyof GetTrashErrors]; @@ -1921,15 +1926,15 @@ export type RestoreTrashItemErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type RestoreTrashItemError = RestoreTrashItemErrors[keyof RestoreTrashItemErrors]; @@ -1959,15 +1964,15 @@ export type DeleteTrashItemErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type DeleteTrashItemError = DeleteTrashItemErrors[keyof DeleteTrashItemErrors]; @@ -1992,19 +1997,19 @@ export type SearchContentErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type SearchContentError = SearchContentErrors[keyof SearchContentErrors]; diff --git a/api/mock-server/src/generated/clear-web-api/contract/zod.gen.ts b/api/mock-server/src/generated/clear-web-api/contract/zod.gen.ts index beb0c56..e112093 100644 --- a/api/mock-server/src/generated/clear-web-api/contract/zod.gen.ts +++ b/api/mock-server/src/generated/clear-web-api/contract/zod.gen.ts @@ -388,18 +388,20 @@ export const zSetActiveWorkspaceRequest = z.object({ workspaceId: zId }); -export const zMessageDomainError = z.object({ +export const zMessageProblemDetails = z.object({ type: z.enum([ - 'conflict', - 'forbidden', - 'not_found', - 'offline', - 'timeout', - 'unauthorized', - 'unexpected', - 'unavailable' + '/problems/bad-request', + '/problems/conflict', + '/problems/forbidden', + '/problems/not-found', + '/problems/timeout', + '/problems/unauthorized', + '/problems/unexpected', + '/problems/unavailable' ]), - message: z.string().min(1), + title: z.string().min(1), + status: z.int().gte(400).lte(599), + detail: z.string().min(1).optional(), retryable: z.boolean(), entity: z.string().optional(), entityId: z.string().optional() @@ -415,31 +417,34 @@ export const zValidationIssue = z.object({ params: z.record(z.string(), z.unknown()).optional() }); -export const zValidationDomainError = z.object({ - type: z.enum(['validation']), +export const zValidationProblemDetails = z.object({ + type: z.enum(['/problems/validation']), + title: z.string().min(1), + status: z.literal(422), + detail: z.string().min(1).optional(), issues: z.array(zValidationIssue), retryable: z.literal(false) }); -export const zComponentsDomainError = z.union([ +export const zComponentsProblemDetails = z.union([ z.object({ - type: z.literal('validation') - }).and(zValidationDomainError), + type: z.literal('/problems/validation') + }).and(zValidationProblemDetails), z.object({ type: z.union([ - z.literal('conflict'), - z.literal('forbidden'), - z.literal('not_found'), - z.literal('offline'), - z.literal('timeout'), - z.literal('unauthorized'), - z.literal('unexpected'), - z.literal('unavailable') + z.literal('/problems/bad-request'), + z.literal('/problems/conflict'), + z.literal('/problems/forbidden'), + z.literal('/problems/not-found'), + z.literal('/problems/timeout'), + z.literal('/problems/unauthorized'), + z.literal('/problems/unexpected'), + z.literal('/problems/unavailable') ]) - }).and(zMessageDomainError) + }).and(zMessageProblemDetails) ]); -export const zDomainError = zComponentsDomainError; +export const zProblemDetails = zComponentsProblemDetails; /** * Lucide icon name used by the UI visual picker. diff --git a/api/mock-server/src/lib/errors.ts b/api/mock-server/src/lib/errors.ts index 4f565f8..5c845d7 100644 --- a/api/mock-server/src/lib/errors.ts +++ b/api/mock-server/src/lib/errors.ts @@ -1,37 +1,71 @@ import { z } from 'zod' +export const ValidationIssueCode = { + Invalid: 'invalid', + InvalidEnum: 'invalid_enum', + InvalidFormat: 'invalid_format', + InvalidValue: 'invalid_value', + Maximum: 'maximum', + MaxLength: 'max_length', + Minimum: 'minimum', + MinLength: 'min_length', + Required: 'required', +} as const + +export type ValidationIssueCode = + (typeof ValidationIssueCode)[keyof typeof ValidationIssueCode] + export const zValidationIssue = z.object({ path: z.array(z.string()).optional(), code: z.string().min(1), params: z.record(z.string(), z.unknown()).optional(), }) -const zMessageDomainError = z.object({ +export const ProblemType = { + BadRequest: '/problems/bad-request', + Conflict: '/problems/conflict', + Forbidden: '/problems/forbidden', + NotFound: '/problems/not-found', + Timeout: '/problems/timeout', + Unauthorized: '/problems/unauthorized', + Unexpected: '/problems/unexpected', + Unavailable: '/problems/unavailable', + Validation: '/problems/validation', +} as const + +export type ProblemType = (typeof ProblemType)[keyof typeof ProblemType] + +const zMessageProblemDetails = z.object({ type: z.enum([ - 'conflict', - 'forbidden', - 'not_found', - 'offline', - 'timeout', - 'unauthorized', - 'unexpected', - 'unavailable', + ProblemType.BadRequest, + ProblemType.Conflict, + ProblemType.Forbidden, + ProblemType.NotFound, + ProblemType.Timeout, + ProblemType.Unauthorized, + ProblemType.Unexpected, + ProblemType.Unavailable, ]), - message: z.string().min(1), + title: z.string().min(1), + status: z.number().int().min(400).max(599), + detail: z.string().min(1).optional(), retryable: z.boolean(), entity: z.string().optional(), entityId: z.string().optional(), }) -const zValidationDomainError = z.object({ - type: z.literal('validation'), +const zValidationProblemDetails = z.object({ + type: z.literal(ProblemType.Validation), + title: z.string().min(1), + status: z.literal(422), + detail: z.string().min(1).optional(), retryable: z.literal(false), issues: z.array(zValidationIssue), }) export const zMockErrorBody = z.discriminatedUnion('type', [ - zMessageDomainError, - zValidationDomainError, + zMessageProblemDetails, + zValidationProblemDetails, ]) export type MockErrorBody = z.infer @@ -42,7 +76,9 @@ export class MockHttpError extends Error { readonly status: number constructor(status: number, body: MockErrorBody) { - super(body.type === 'validation' ? 'Validation failed' : body.message) + super( + body.type === ProblemType.Validation ? 'Validation failed' : (body.detail ?? body.title), + ) this.status = status this.body = body } @@ -50,36 +86,48 @@ export class MockHttpError extends Error { const messageError = ( status: number, - type: Exclude, - message: string, + type: Exclude, + title: string, + detail: string, retryable = false, metadata?: { entity?: string; entityId?: string }, ) => new MockHttpError(status, { type, - message, + title, + status, + detail, retryable, ...metadata, }) export const badRequest = (message: string) => - messageError(400, 'unexpected', message) + messageError(400, ProblemType.BadRequest, 'Bad Request', message) export const validationError = (issues: ValidationIssue[]) => new MockHttpError(422, { - type: 'validation', + type: ProblemType.Validation, + title: 'Validation Failed', + status: 422, retryable: false, issues, }) export const notFound = (resource: string, id: string) => - messageError(404, 'not_found', `${resource} ${id} was not found`, false, { - entity: resource, - entityId: id, - }) + messageError( + 404, + ProblemType.NotFound, + 'Not Found', + `${resource} ${id} was not found`, + false, + { + entity: resource, + entityId: id, + }, + ) export const conflict = (message: string) => - messageError(409, 'conflict', message) + messageError(409, ProblemType.Conflict, 'Conflict', message) export const unexpected = (message = 'Unexpected mock server error') => - messageError(500, 'unexpected', message) + messageError(500, ProblemType.Unexpected, 'Unexpected Error', message) diff --git a/api/mock-server/src/lib/honoMockRuntime.ts b/api/mock-server/src/lib/honoMockRuntime.ts index e21d137..630fc64 100644 --- a/api/mock-server/src/lib/honoMockRuntime.ts +++ b/api/mock-server/src/lib/honoMockRuntime.ts @@ -2,6 +2,7 @@ import type { Hono } from 'hono' import { ZodError, type ZodIssue, type z } from 'zod' import { + ValidationIssueCode, badRequest, MockHttpError, unexpected, @@ -37,10 +38,21 @@ const methods = { } as const export const mockJsonResponse = (body: unknown, status: number, headers?: Headers) => + mockBodyResponse(body, status, 'application/json', headers) + +export const mockProblemResponse = (body: unknown, status: number, headers?: Headers) => + mockBodyResponse(body, status, 'application/problem+json', headers) + +const mockBodyResponse = ( + body: unknown, + status: number, + contentType: string, + headers?: Headers, +) => new Response(JSON.stringify(body), { headers: { - 'content-type': 'application/json', ...(headers ? Object.fromEntries(headers.entries()) : {}), + 'content-type': contentType, }, status, }) @@ -98,16 +110,20 @@ const toValidationIssueCode = ( switch (issue.code) { case 'invalid_type': return typeof issue.message === 'string' && issue.message.includes('received undefined') - ? 'required' - : 'invalid' + ? ValidationIssueCode.Required + : ValidationIssueCode.Invalid case 'too_small': - return rawIssue.origin === 'string' ? 'min_length' : 'minimum' + return rawIssue.origin === 'string' + ? ValidationIssueCode.MinLength + : ValidationIssueCode.Minimum case 'too_big': - return rawIssue.origin === 'string' ? 'max_length' : 'maximum' + return rawIssue.origin === 'string' + ? ValidationIssueCode.MaxLength + : ValidationIssueCode.Maximum case 'invalid_value': - return 'invalid_value' + return ValidationIssueCode.InvalidValue case 'invalid_format': - return 'invalid_format' + return ValidationIssueCode.InvalidFormat default: return issue.code } @@ -185,7 +201,7 @@ export const registerGeneratedMockRoutes = ( ? unexpected(caught.message) : unexpected() - return mockJsonResponse(error.body, error.status, requestContext.responseHeaders) + return mockProblemResponse(error.body, error.status, requestContext.responseHeaders) } }) } diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml index 985d748..cd173e4 100644 --- a/api/openapi/openapi.yaml +++ b/api/openapi/openapi.yaml @@ -132,12 +132,12 @@ components: $ref: './domains/bootstrap.yaml#/components/schemas/BootstrapResult' Deck: $ref: './domains/decks.yaml#/components/schemas/Deck' - DomainError: - $ref: './shared/components.yaml#/components/schemas/DomainError' Folder: $ref: './domains/folders.yaml#/components/schemas/Folder' NoteDetail: $ref: './domains/notes.yaml#/components/schemas/NoteDetail' + ProblemDetails: + $ref: './shared/components.yaml#/components/schemas/ProblemDetails' ReviewCard: $ref: './domains/review.yaml#/components/schemas/ReviewCard' ReviewSession: diff --git a/api/openapi/shared/components.yaml b/api/openapi/shared/components.yaml index caaad46..9e63bbb 100644 --- a/api/openapi/shared/components.yaml +++ b/api/openapi/shared/components.yaml @@ -92,26 +92,30 @@ components: BadRequest: description: The request was malformed. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/DomainError' + $ref: '#/components/schemas/ProblemDetails' examples: badRequest: value: - type: unexpected - message: Request body is malformed. + type: /problems/bad-request + title: Bad Request + status: 400 + detail: Request body is malformed. retryable: false Validation: description: The request failed domain validation. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/DomainError' + $ref: '#/components/schemas/ProblemDetails' examples: validation: value: - type: validation + type: /problems/validation + title: Validation Failed + status: 422 retryable: false issues: - path: @@ -121,28 +125,30 @@ components: Unauthorized: description: Authentication is required. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/DomainError' + $ref: '#/components/schemas/ProblemDetails' Forbidden: description: The caller is not allowed to perform this action. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/DomainError' + $ref: '#/components/schemas/ProblemDetails' NotFound: description: The requested resource was not found. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/DomainError' + $ref: '#/components/schemas/ProblemDetails' examples: notFound: value: - type: not_found - message: Workspace not found. + type: /problems/not-found + title: Not Found + status: 404 + detail: Workspace not found. retryable: false entity: workspace entityId: editorial-production @@ -150,30 +156,30 @@ components: Conflict: description: The request conflicts with current resource state. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/DomainError' + $ref: '#/components/schemas/ProblemDetails' Timeout: description: The operation timed out. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/DomainError' + $ref: '#/components/schemas/ProblemDetails' Unavailable: description: The service is temporarily unavailable. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/DomainError' + $ref: '#/components/schemas/ProblemDetails' Unexpected: description: An unexpected error occurred. content: - application/json: + application/problem+json: schema: - $ref: '#/components/schemas/DomainError' + $ref: '#/components/schemas/ProblemDetails' schemas: Id: @@ -221,19 +227,20 @@ components: description: Lucide icon name used by the UI visual picker. example: book-open - DomainErrorType: + ProblemType: type: string + format: uri-reference enum: - - conflict - - forbidden - - not_found - - offline - - timeout - - unauthorized - - unexpected - - unavailable - - validation - example: validation + - /problems/bad-request + - /problems/conflict + - /problems/forbidden + - /problems/not-found + - /problems/timeout + - /problems/unauthorized + - /problems/unexpected + - /problems/unavailable + - /problems/validation + example: /problems/validation ValidationIssue: type: object @@ -256,18 +263,32 @@ components: - title code: required - ValidationDomainError: + ValidationProblemDetails: type: object additionalProperties: false required: - issues - retryable + - status + - title - type properties: type: type: string enum: - - validation + - /problems/validation + format: uri-reference + title: + type: string + minLength: 1 + example: Validation Failed + status: + type: integer + enum: + - 422 + detail: + type: string + minLength: 1 issues: type: array items: @@ -277,33 +298,44 @@ components: enum: - false example: - type: validation + type: /problems/validation + title: Validation Failed + status: 422 retryable: false issues: - path: - title code: required - MessageDomainError: + MessageProblemDetails: type: object additionalProperties: false required: - - message - retryable + - status + - title - type properties: type: type: string + format: uri-reference enum: - - conflict - - forbidden - - not_found - - offline - - timeout - - unauthorized - - unexpected - - unavailable - message: + - /problems/bad-request + - /problems/conflict + - /problems/forbidden + - /problems/not-found + - /problems/timeout + - /problems/unauthorized + - /problems/unexpected + - /problems/unavailable + title: + type: string + minLength: 1 + status: + type: integer + minimum: 400 + maximum: 599 + detail: type: string minLength: 1 retryable: @@ -313,31 +345,35 @@ components: entityId: type: string - DomainError: + ProblemDetails: oneOf: - - $ref: '#/components/schemas/ValidationDomainError' - - $ref: '#/components/schemas/MessageDomainError' + - $ref: '#/components/schemas/ValidationProblemDetails' + - $ref: '#/components/schemas/MessageProblemDetails' discriminator: propertyName: type mapping: - conflict: '#/components/schemas/MessageDomainError' - forbidden: '#/components/schemas/MessageDomainError' - not_found: '#/components/schemas/MessageDomainError' - offline: '#/components/schemas/MessageDomainError' - timeout: '#/components/schemas/MessageDomainError' - unauthorized: '#/components/schemas/MessageDomainError' - unexpected: '#/components/schemas/MessageDomainError' - unavailable: '#/components/schemas/MessageDomainError' - validation: '#/components/schemas/ValidationDomainError' + /problems/bad-request: '#/components/schemas/MessageProblemDetails' + /problems/conflict: '#/components/schemas/MessageProblemDetails' + /problems/forbidden: '#/components/schemas/MessageProblemDetails' + /problems/not-found: '#/components/schemas/MessageProblemDetails' + /problems/timeout: '#/components/schemas/MessageProblemDetails' + /problems/unauthorized: '#/components/schemas/MessageProblemDetails' + /problems/unexpected: '#/components/schemas/MessageProblemDetails' + /problems/unavailable: '#/components/schemas/MessageProblemDetails' + /problems/validation: '#/components/schemas/ValidationProblemDetails' examples: - - type: validation + - type: /problems/validation + title: Validation Failed + status: 422 retryable: false issues: - path: - title code: required - - type: not_found - message: Note not found. + - type: /problems/not-found + title: Not Found + status: 404 + detail: Note not found. retryable: false entity: note entityId: constitutional-crisis diff --git a/skills/react-vite-structure/references/error-handling.md b/skills/react-vite-structure/references/error-handling.md index 4d2eb55..7e3163a 100644 --- a/skills/react-vite-structure/references/error-handling.md +++ b/skills/react-vite-structure/references/error-handling.md @@ -224,6 +224,15 @@ shared error translation pattern and validation-field translation boundary. Keep HTTP and API-client specific logic outside `shared/errors`: +For owned HTTP APIs, use RFC 9457 Problem Details as the wire error contract: +publish errors as `application/problem+json` with `type`, `title`, `status`, +optional `detail`, and project-specific extension members. Keep internal +`DomainError` as the service/UI contract; do not expose it as the default HTTP +wire schema. Generated `ProblemDetails` types stay under +`shared/services/api/generated//`, and +`shared/services/api/error-mapping.ts` converts them into complete +`DomainError` values. + ```ts // shared/services/api/error-mapping.ts import { domainError, isDomainError, type DomainError } from '@shared/errors'; @@ -237,8 +246,9 @@ export function mapApiErrorToDomainError( } // Map the current HTTP client here: - // - validation payloads -> domainError.validation(...) - // - 401/403/404/409/429 -> matching domain errors + // - ProblemDetails type/status/detail -> matching domain errors + // - ProblemDetails validation extensions -> domainError.validation(...) + // - transport statuses without usable payloads -> matching domain errors // - timeouts/offline/5xx -> domainError.network(...) // - unknown failures -> domainError.unexpected(...) @@ -256,11 +266,11 @@ string messages only as debug/fallback data outside the main UI contract when a project explicitly needs them. `mapApiErrorToDomainError()` is also the right place to handle partial -transport-shaped errors. If a payload has a known domain error `type` but is -missing or malformed fields, preserve the type when practical by rebuilding a +transport-shaped errors. If a Problem Details payload has a known `type`, +`status`, or usable extension members but is missing optional fields, rebuild a complete `DomainError` through `domainError.*(...)` with safe defaults. Fall -back to `domainError.unexpected(...)` only when the type is unknown or the -payload cannot be interpreted safely. +back to status-based mapping or `domainError.unexpected(...)` when the payload +cannot be interpreted safely. If the project uses Axios, Axios-specific checks stay in this file. Do not import Axios from `shared/errors`. @@ -292,10 +302,10 @@ export function mapTauriErrorToDomainError( } ``` -Apply the same partial-payload policy here as in `mapApiErrorToDomainError()`: -`isDomainError()` accepts only complete serialized domain errors, while -`mapTauriErrorToDomainError()` may preserve known domain error types by -rebuilding full errors with safe defaults. +For Tauri, keep the serialized internal `DomainError` boundary separate from +HTTP Problem Details: `isDomainError()` accepts only complete serialized domain +errors, while `mapTauriErrorToDomainError()` may preserve known domain error +types by rebuilding full errors with safe defaults. Wrap Tauri invoke calls to remove repeated `try/catch` from services: @@ -399,7 +409,8 @@ Test: - validation issue paths, codes, params, and form-level issues without paths; - `isDomainError()` rejecting malformed partial payloads instead of coercing them; - retryability rules; -- API status/payload mapping for the current HTTP client; -- API/Tauri mappers preserving known domain error types from partial serialized payloads by rebuilding complete `DomainError` values; +- API Problem Details mapping by `type`, `status`, `detail`/`title`, and extension members; +- malformed validation extensions, unknown problem types, and status-only fallbacks; +- API mappers rebuilding complete `DomainError` values from usable Problem Details payloads; - Tauri unknown fallback and pass-through of complete serialized `DomainError`; - `unwrapDomainResult` resolving values and rejecting with `DomainError`. diff --git a/ui/src/platform/mock/mockDomainResult.ts b/ui/src/platform/mock/mockDomainResult.ts index 4abe4a2..be48a7f 100644 --- a/ui/src/platform/mock/mockDomainResult.ts +++ b/ui/src/platform/mock/mockDomainResult.ts @@ -7,11 +7,11 @@ import { import { domainError, err, - isDomainError, ok, type DomainError, type Result, } from '@shared/errors' +import { mapApiErrorToDomainError } from '@shared/services/api/error-mapping' import type { DueReviewSession, ReviewSession, @@ -61,9 +61,5 @@ const toMockDomainError = (error: unknown): DomainError => { return domainError.unexpected('Mock service failed.') } - if (isDomainError(error.body)) { - return error.body - } - - return domainError.unexpected(error.message) + return mapApiErrorToDomainError(error.body, error.message) } diff --git a/ui/src/platform/services/decks/web/deckService.test.ts b/ui/src/platform/services/decks/web/deckService.test.ts index 7670175..d74cfbf 100644 --- a/ui/src/platform/services/decks/web/deckService.test.ts +++ b/ui/src/platform/services/decks/web/deckService.test.ts @@ -148,7 +148,9 @@ describe('webDeckService', () => { { issues: [{ code: 'required', path: ['title'] }], retryable: false, - type: DomainErrorType.Validation, + status: 422, + title: 'Validation Failed', + type: '/problems/validation', }, { status: 422 }, ), diff --git a/ui/src/shared/errors/index.test.ts b/ui/src/shared/errors/index.test.ts index ad3ca21..07eb050 100644 --- a/ui/src/shared/errors/index.test.ts +++ b/ui/src/shared/errors/index.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest' -import { DomainErrorType, domainError, err, isDomainError, ok } from './index' +import { + DomainErrorType, + ValidationIssueCode, + domainError, + err, + isDomainError, + isValidationIssue, + ok, +} from './index' describe('domain errors', () => { it('creates typed results', () => { @@ -62,6 +70,13 @@ describe('domain errors', () => { it('strictly detects validation domain error payloads', () => { expect(isDomainError(domainError.validation([{ code: 'required' }]))).toBe(true) + expect( + isDomainError({ + issues: [{ code: '' }], + retryable: false, + type: DomainErrorType.Validation, + }), + ).toBe(false) expect( isDomainError({ fieldErrors: { title: ['Required'] }, @@ -70,5 +85,79 @@ describe('domain errors', () => { type: DomainErrorType.Validation, }), ).toBe(false) + expect( + isDomainError({ + issues: [{ code: 'required' }], + retryable: false, + type: 'custom', + }), + ).toBe(false) + }) + + it('strictly detects validation issue payloads', () => { + expect(isValidationIssue({ code: ValidationIssueCode.Required })).toBe(true) + expect( + isValidationIssue({ + code: ValidationIssueCode.Minimum, + params: { min: 1 }, + path: ['settings', 'limit'], + }), + ).toBe(true) + expect(isValidationIssue({ code: 'custom_code' })).toBe(true) + + expect(isValidationIssue(null)).toBe(false) + expect(isValidationIssue('required')).toBe(false) + expect(isValidationIssue({ path: ['title'] })).toBe(false) + expect(isValidationIssue({ code: '' })).toBe(false) + expect(isValidationIssue({ code: 42 })).toBe(false) + expect(isValidationIssue({ code: 'required', path: 'title' })).toBe(false) + expect(isValidationIssue({ code: 'required', path: ['title', 0] })).toBe(false) + expect(isValidationIssue({ code: 'minimum', params: null })).toBe(false) + expect(isValidationIssue({ code: 'minimum', params: ['min'] })).toBe(false) + }) + + it('strictly detects message domain error payloads', () => { + expect(isDomainError(domainError.notFound('Missing note', 'note', 'note-1'))).toBe( + true, + ) + expect(isDomainError(domainError.offline('No connection'))).toBe(true) + + expect( + isDomainError({ + message: 42, + retryable: false, + type: DomainErrorType.NotFound, + }), + ).toBe(false) + expect( + isDomainError({ + message: 'Missing', + retryable: true, + type: DomainErrorType.NotFound, + }), + ).toBe(false) + expect( + isDomainError({ + message: 'No connection', + retryable: false, + type: DomainErrorType.Offline, + }), + ).toBe(false) + expect( + isDomainError({ + entity: 42, + message: 'Missing', + retryable: false, + type: DomainErrorType.NotFound, + }), + ).toBe(false) + expect( + isDomainError({ + entityId: 42, + message: 'Missing', + retryable: false, + type: DomainErrorType.NotFound, + }), + ).toBe(false) }) }) diff --git a/ui/src/shared/errors/index.ts b/ui/src/shared/errors/index.ts index 049049a..5ade0ea 100644 --- a/ui/src/shared/errors/index.ts +++ b/ui/src/shared/errors/index.ts @@ -1,3 +1,5 @@ +import { z } from 'zod' + export const DomainErrorType = { Conflict: 'conflict', Forbidden: 'forbidden', @@ -19,6 +21,27 @@ export type ValidationIssue = { params?: Record } +export const ValidationIssueCode = { + Invalid: 'invalid', + InvalidEnum: 'invalid_enum', + InvalidFormat: 'invalid_format', + InvalidValue: 'invalid_value', + Maximum: 'maximum', + MaxLength: 'max_length', + Minimum: 'minimum', + MinLength: 'min_length', + Required: 'required', +} as const + +export type ValidationIssueCode = + (typeof ValidationIssueCode)[keyof typeof ValidationIssueCode] + +const validationIssueSchema = z.object({ + path: z.array(z.string()).optional(), + code: z.string().min(1), + params: z.record(z.string(), z.unknown()).optional(), +}) + export type DomainError = | { type: typeof DomainErrorType.Validation @@ -96,77 +119,49 @@ export const domainError = { }, } as const -export const isDomainError = (value: unknown): value is DomainError => { - if (typeof value !== 'object' || value === null) { - return false - } - - const candidate = value as { - entity?: unknown - entityId?: unknown - issues?: unknown - message?: unknown - retryable?: unknown - type?: unknown - } - - if ( - typeof candidate.type !== 'string' || - !Object.values(DomainErrorType).includes(candidate.type as DomainErrorType) - ) { - return false - } - - if (candidate.type === DomainErrorType.Validation) { - return candidate.retryable === false && isValidationIssues(candidate.issues) - } - - if (typeof candidate.message !== 'string') { - return false - } - - switch (candidate.type) { - case DomainErrorType.Conflict: - case DomainErrorType.Forbidden: - case DomainErrorType.NotFound: - case DomainErrorType.Unauthorized: - case DomainErrorType.Unexpected: - return ( - candidate.retryable === false && - (candidate.entity === undefined || typeof candidate.entity === 'string') && - (candidate.entityId === undefined || typeof candidate.entityId === 'string') - ) - case DomainErrorType.Offline: - case DomainErrorType.Timeout: - case DomainErrorType.Unavailable: - return candidate.retryable === true - } - - return false -} +const messageDomainErrorSchema = z.object({ + message: z.string(), +}) + +const nonRetryableMessageDomainErrorSchema = messageDomainErrorSchema.extend({ + type: z.enum([ + DomainErrorType.Conflict, + DomainErrorType.Forbidden, + DomainErrorType.NotFound, + DomainErrorType.Unauthorized, + DomainErrorType.Unexpected, + ]), + retryable: z.literal(false), + entity: z.string().optional(), + entityId: z.string().optional(), +}) + +const retryableMessageDomainErrorSchema = messageDomainErrorSchema.extend({ + type: z.enum([ + DomainErrorType.Offline, + DomainErrorType.Timeout, + DomainErrorType.Unavailable, + ]), + retryable: z.literal(true), +}) + +const validationDomainErrorSchema = z.object({ + type: z.literal(DomainErrorType.Validation), + issues: z.array(validationIssueSchema), + retryable: z.literal(false), +}) + +const domainErrorSchema = z.union([ + validationDomainErrorSchema, + nonRetryableMessageDomainErrorSchema, + retryableMessageDomainErrorSchema, +]) + +export const isDomainError = (value: unknown): value is DomainError => + domainErrorSchema.safeParse(value).success export const isValidationIssues = (value: unknown): value is ValidationIssue[] => Array.isArray(value) && value.every(isValidationIssue) -export const isValidationIssue = (value: unknown): value is ValidationIssue => { - if (typeof value !== 'object' || value === null) { - return false - } - - const candidate = value as { - code?: unknown - params?: unknown - path?: unknown - } - - return ( - typeof candidate.code === 'string' && - (candidate.path === undefined || - (Array.isArray(candidate.path) && - candidate.path.every((segment) => typeof segment === 'string'))) && - (candidate.params === undefined || - (typeof candidate.params === 'object' && - candidate.params !== null && - !Array.isArray(candidate.params))) - ) -} +export const isValidationIssue = (value: unknown): value is ValidationIssue => + validationIssueSchema.safeParse(value).success diff --git a/ui/src/shared/errors/translation.test.ts b/ui/src/shared/errors/translation.test.ts index 7c16f5d..2edf621 100644 --- a/ui/src/shared/errors/translation.test.ts +++ b/ui/src/shared/errors/translation.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { createAppI18n } from '@core/i18n' -import { domainError, type DomainError } from './index' +import { ValidationIssueCode, domainError, type DomainError } from './index' import { translateDomainError, translateValidationIssue, @@ -65,34 +65,45 @@ describe('translateDomainError', () => { it.each([ { expected: 'Title is required.', - issue: { code: 'required' }, + issue: { code: ValidationIssueCode.Required }, name: 'required', }, { expected: 'Title must be at least 3 characters.', - issue: { code: 'min_length', params: { min: 3, valueType: 'string' } }, + issue: { + code: ValidationIssueCode.MinLength, + params: { min: 3, valueType: 'string' }, + }, name: 'min length', }, { expected: 'Title must be at most 20 characters.', - issue: { code: 'max_length', params: { max: 20, valueType: 'string' } }, + issue: { + code: ValidationIssueCode.MaxLength, + params: { max: 20, valueType: 'string' }, + }, name: 'max length', }, { expected: 'Title must be at least 1.', - issue: { code: 'minimum', params: { min: 1 } }, + issue: { code: ValidationIssueCode.Minimum, params: { min: 1 } }, name: 'minimum', }, { expected: 'Title must be at most 100.', - issue: { code: 'maximum', params: { max: 100 } }, + issue: { code: ValidationIssueCode.Maximum, params: { max: 100 } }, name: 'maximum', }, { expected: 'Enter a valid Title.', - issue: { code: 'invalid_format' }, + issue: { code: ValidationIssueCode.InvalidFormat }, name: 'invalid format', }, + { + expected: 'Title is invalid.', + issue: { code: 'custom_code' }, + name: 'unknown code fallback', + }, ])('translates $name validation issues', ({ expected, issue }) => { expect(translateValidationIssue(createAppI18n().t, issue, 'Title')).toBe(expected) }) diff --git a/ui/src/shared/errors/translation.ts b/ui/src/shared/errors/translation.ts index dbb8368..35fb3da 100644 --- a/ui/src/shared/errors/translation.ts +++ b/ui/src/shared/errors/translation.ts @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next' import { DomainErrorType, + ValidationIssueCode, isDomainError, type DomainError, type ValidationIssue, @@ -44,30 +45,31 @@ export const translateValidationIssue = ( fieldLabel: string, ): string => { switch (issue.code) { - case 'required': + case ValidationIssueCode.Required: return t(($) => $.forms.validation.required, { field: fieldLabel }) - case 'min_length': + case ValidationIssueCode.MinLength: case 'too_small': return translateLengthOrNumberMinimum(t, issue, fieldLabel) - case 'max_length': + case ValidationIssueCode.MaxLength: case 'too_big': return translateLengthOrNumberMaximum(t, issue, fieldLabel) - case 'minimum': + case ValidationIssueCode.Minimum: return t(($) => $.forms.validation.minimum, { field: fieldLabel, min: formatParamValue(issue.params?.min ?? issue.params?.minimum), }) - case 'maximum': + case ValidationIssueCode.Maximum: return t(($) => $.forms.validation.maximum, { field: fieldLabel, max: formatParamValue(issue.params?.max ?? issue.params?.maximum), }) - case 'invalid_enum': - case 'invalid_value': + case ValidationIssueCode.InvalidEnum: + case ValidationIssueCode.InvalidValue: return t(($) => $.forms.validation.invalidEnum, { field: fieldLabel }) - case 'invalid_format': + case ValidationIssueCode.InvalidFormat: case 'invalid_string': return t(($) => $.forms.validation.invalidFormat, { field: fieldLabel }) + case ValidationIssueCode.Invalid: default: return t(($) => $.forms.validation.invalid, { field: fieldLabel }) } diff --git a/ui/src/shared/services/api/error-mapping.test.ts b/ui/src/shared/services/api/error-mapping.test.ts index 9cc719b..10ac238 100644 --- a/ui/src/shared/services/api/error-mapping.test.ts +++ b/ui/src/shared/services/api/error-mapping.test.ts @@ -37,7 +37,13 @@ describe('mapApiErrorToDomainError', () => { expect( mapApiErrorToDomainError({ response: { - data: { message: 'Gateway failed.' }, + data: { + detail: 'Gateway failed.', + retryable: true, + status, + title: 'Service Unavailable', + type: '/problems/unavailable', + }, status, }, }), @@ -48,14 +54,16 @@ describe('mapApiErrorToDomainError', () => { }) }) - it('keeps 422 API validation errors as issue validation', () => { + it('maps 422 problem validation errors to issue validation', () => { expect( mapApiErrorToDomainError({ response: { data: { issues: [{ code: 'required', path: ['title'] }], retryable: false, - type: DomainErrorType.Validation, + status: 422, + title: 'Validation Failed', + type: '/problems/validation', }, status: 422, }, @@ -73,6 +81,10 @@ describe('mapApiErrorToDomainError', () => { response: { data: { issues: [{ code: 42, path: ['title'] }], + retryable: false, + status: 422, + title: 'Validation Failed', + type: '/problems/validation', }, status: 422, }, @@ -83,6 +95,91 @@ describe('mapApiErrorToDomainError', () => { }) }) + it('maps not found problem metadata to domain error metadata', () => { + expect( + mapApiErrorToDomainError({ + response: { + data: { + detail: 'Workspace missing.', + entity: 'workspace', + entityId: 'workspace-1', + retryable: false, + status: 404, + title: 'Not Found', + type: '/problems/not-found', + }, + status: 404, + }, + }), + ).toEqual({ + entity: 'workspace', + entityId: 'workspace-1', + message: 'Workspace missing.', + retryable: false, + type: DomainErrorType.NotFound, + }) + }) + + it('maps bad request and conflict problems to domain errors', () => { + expect( + mapApiErrorToDomainError({ + response: { + data: { + detail: 'Request body must be valid JSON.', + retryable: false, + status: 400, + title: 'Bad Request', + type: '/problems/bad-request', + }, + status: 400, + }, + }), + ).toMatchObject({ + message: 'Request body must be valid JSON.', + retryable: false, + type: DomainErrorType.Unexpected, + }) + + expect( + mapApiErrorToDomainError({ + response: { + data: { + detail: 'Workspace already exists.', + retryable: false, + status: 409, + title: 'Conflict', + type: '/problems/conflict', + }, + status: 409, + }, + }), + ).toMatchObject({ + message: 'Workspace already exists.', + retryable: false, + type: DomainErrorType.Conflict, + }) + }) + + it('falls back to HTTP status for unknown problem types', () => { + expect( + mapApiErrorToDomainError({ + response: { + data: { + detail: 'Custom conflict.', + status: 409, + title: 'Custom Conflict', + type: '/problems/custom-conflict', + }, + status: 409, + }, + }), + ).toMatchObject({ + message: 'Custom conflict.', + retryable: false, + type: DomainErrorType.Conflict, + }) + }) + it('keeps network and timeout errors retryable', () => { expect(mapApiErrorToDomainError({ code: 'ERR_NETWORK' })).toMatchObject({ retryable: true, diff --git a/ui/src/shared/services/api/error-mapping.ts b/ui/src/shared/services/api/error-mapping.ts index e2d30d7..47c101b 100644 --- a/ui/src/shared/services/api/error-mapping.ts +++ b/ui/src/shared/services/api/error-mapping.ts @@ -25,6 +25,18 @@ type ApiErrorInfo = { status?: number } +const ProblemType = { + BadRequest: '/problems/bad-request', + Conflict: '/problems/conflict', + Forbidden: '/problems/forbidden', + NotFound: '/problems/not-found', + Timeout: '/problems/timeout', + Unauthorized: '/problems/unauthorized', + Unexpected: '/problems/unexpected', + Unavailable: '/problems/unavailable', + Validation: '/problems/validation', +} as const + export const mapApiErrorToDomainError = ( error: unknown, fallbackMessage = 'Request failed.', @@ -39,6 +51,12 @@ export const mapApiErrorToDomainError = ( return domainError.unavailable(fallbackMessage) } + const problemError = mapProblemDetailsToDomainError(payload, message) + + if (problemError) { + return problemError + } + switch (code) { case 'ERR_NETWORK': return domainError.offline(message) @@ -53,7 +71,11 @@ export const mapApiErrorToDomainError = ( case 403: return domainError.forbidden(message) case 404: - return domainError.notFound(message) + return domainError.notFound( + message, + readStringProperty(payload, 'entity'), + readStringProperty(payload, 'entityId'), + ) case 408: return domainError.timeout(message) case 409: @@ -79,16 +101,60 @@ const readApiError = (error: unknown, fallbackMessage: string): ApiErrorInfo => const candidate = error as ErrorLike const payload = candidate.error ?? candidate.response?.data ?? error - const status = candidate.response?.status + const responseStatus = candidate.response?.status const code = candidate.code - const payloadMessage = isObject(payload) ? payload.message : undefined + const payloadStatus = isObject(payload) ? payload.status : undefined + const payloadMessage = readProblemMessage(payload) return { code: typeof code === 'string' ? code : undefined, message: readMessage(payloadMessage) ?? readMessage(candidate.message) ?? fallbackMessage, payload, - status: typeof status === 'number' ? status : undefined, + status: + typeof responseStatus === 'number' + ? responseStatus + : typeof payloadStatus === 'number' + ? payloadStatus + : undefined, + } +} + +const mapProblemDetailsToDomainError = ( + payload: unknown, + fallbackMessage: string, +): DomainError | undefined => { + if (!isObject(payload) || typeof payload.type !== 'string') { + return undefined + } + + const message = readProblemMessage(payload) ?? fallbackMessage + + switch (payload.type) { + case ProblemType.BadRequest: + return domainError.unexpected(message) + case ProblemType.Conflict: + return domainError.conflict(message) + case ProblemType.Forbidden: + return domainError.forbidden(message) + case ProblemType.NotFound: + return domainError.notFound( + message, + readStringProperty(payload, 'entity'), + readStringProperty(payload, 'entityId'), + ) + case ProblemType.Timeout: + return domainError.timeout(message) + case ProblemType.Unauthorized: + return domainError.unauthorized(message) + case ProblemType.Unexpected: + return domainError.unexpected(message) + case ProblemType.Unavailable: + return domainError.unavailable(message) + case ProblemType.Validation: + return domainError.validation(getValidationIssues(payload)) + default: + return undefined } } @@ -109,5 +175,23 @@ const isResponseValidationError = (error: unknown) => const readMessage = (message: unknown) => typeof message === 'string' && message.trim().length > 0 ? message : undefined +const readProblemMessage = (value: unknown) => { + if (!isObject(value)) { + return undefined + } + + return readMessage(value.detail) ?? readMessage(value.title) ?? readMessage(value.message) +} + +const readStringProperty = (value: unknown, property: string) => { + if (!isObject(value)) { + return undefined + } + + const propertyValue = value[property] + + return typeof propertyValue === 'string' ? propertyValue : undefined +} + const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null diff --git a/ui/src/shared/services/api/generated/clear-api/index.ts b/ui/src/shared/services/api/generated/clear-api/index.ts index 33ea07b..a19cfd6 100644 --- a/ui/src/shared/services/api/generated/clear-api/index.ts +++ b/ui/src/shared/services/api/generated/clear-api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { bootstrap, createDeck, createFolder, createNote, createWorkspace, deleteDeck, deleteFolder, deleteNote, deleteTrashItem, deleteWorkspace, emptyTrash, getActiveWorkspace, getDeck, getDefaultSettings, getFolder, getFolderPath, getNote, getReviewSession, getSettings, getTrash, getWorkspace, gradeReviewSessionCard, listFolderDecks, listFolderFolders, listNotesByDeck, listWorkspaceDecks, listWorkspaceFolders, listWorkspaces, type Options, resetSettings, restoreTrashItem, searchContent, setActiveWorkspace, startReviewSession, updateDeck, updateFolder, updateNote, updateSettings, updateWorkspace } from './sdk.gen'; -export type { ActiveWorkspace, ActiveWorkspace2, BasicNote, BasicNoteDraft, BasicNoteEditor, BasicReviewCard, Bootstrap, BootstrapBootstrapResult, BootstrapData, BootstrapError, BootstrapErrors, BootstrapResponse, BootstrapResponses, BootstrapResult, CardId, ClientOptions, ClozeNote, ClozeNoteCard, ClozeNoteDraft, ClozeNoteEditor, ClozeReviewCard, ComponentsCardId, ComponentsDeckId, ComponentsDeckSortField, ComponentsDomainError, ComponentsFolderId, ComponentsFolderSortField, ComponentsItemId, ComponentsNoteId, ComponentsNoteSortField, ComponentsReviewId, ComponentsSortDirection, ComponentsWorkspaceId, CreateDeckData, CreateDeckError, CreateDeckErrors, CreateDeckResponse, CreateDeckResponses, CreateFolderData, CreateFolderError, CreateFolderErrors, CreateFolderResponse, CreateFolderResponses, CreateNoteData, CreateNoteError, CreateNoteErrors, CreateNoteResponse, CreateNoteResponses, CreateWorkspaceData, CreateWorkspaceError, CreateWorkspaceErrors, CreateWorkspaceResponse, CreateWorkspaceResponses, DateTime, Deck, DeckById, DeckDraft, DeckId, DeckNotes, DeckReviews, Decks, DecksDeck, DeckSearchResult, DeckSearchResultGroup, DeckSearchScope, DeckSortField, DeckSortField2, DeleteDeckData, DeleteDeckError, DeleteDeckErrors, DeleteDeckResponse, DeleteDeckResponses, DeleteFolderData, DeleteFolderError, DeleteFolderErrors, DeleteFolderResponse, DeleteFolderResponses, DeleteNoteData, DeleteNoteError, DeleteNoteErrors, DeleteNoteResponse, DeleteNoteResponses, DeleteTrashItemData, DeleteTrashItemError, DeleteTrashItemErrors, DeleteTrashItemResponse, DeleteTrashItemResponses, DeleteWorkspaceData, DeleteWorkspaceError, DeleteWorkspaceErrors, DeleteWorkspaceResponse, DeleteWorkspaceResponses, DeleteWorkspaceResult, DomainError, DueReviewSession, DueReviewSessionStatus, EmptyTrashData, EmptyTrashError, EmptyTrashErrors, EmptyTrashResponse, EmptyTrashResponses, Folder, FolderById, FolderDecks, FolderDraft, FolderFolders, FolderId, FolderPath, FolderPath2, Folders, FolderSearchResult, FolderSearchResultGroup, FolderSearchScope, FoldersFolder, FolderSortField, FolderSortField2, GetActiveWorkspaceData, GetActiveWorkspaceError, GetActiveWorkspaceErrors, GetActiveWorkspaceResponse, GetActiveWorkspaceResponses, GetDeckData, GetDeckError, GetDeckErrors, GetDeckResponse, GetDeckResponses, GetDefaultSettingsData, GetDefaultSettingsError, GetDefaultSettingsErrors, GetDefaultSettingsResponse, GetDefaultSettingsResponses, GetFolderData, GetFolderError, GetFolderErrors, GetFolderPathData, GetFolderPathError, GetFolderPathErrors, GetFolderPathResponse, GetFolderPathResponses, GetFolderResponse, GetFolderResponses, GetNoteData, GetNoteError, GetNoteErrors, GetNoteResponse, GetNoteResponses, GetReviewSessionData, GetReviewSessionError, GetReviewSessionErrors, GetReviewSessionResponse, GetReviewSessionResponses, GetSettingsData, GetSettingsError, GetSettingsErrors, GetSettingsResponse, GetSettingsResponses, GetTrashData, GetTrashError, GetTrashErrors, GetTrashResponse, GetTrashResponses, GetWorkspaceData, GetWorkspaceError, GetWorkspaceErrors, GetWorkspaceResponse, GetWorkspaceResponses, GradeReviewCardRequest, GradeReviewSessionCard, GradeReviewSessionCardData, GradeReviewSessionCardError, GradeReviewSessionCardErrors, GradeReviewSessionCardResponse, GradeReviewSessionCardResponses, Id, ItemId, ListFolderDecksData, ListFolderDecksError, ListFolderDecksErrors, ListFolderDecksResponse, ListFolderDecksResponses, ListFolderFoldersData, ListFolderFoldersError, ListFolderFoldersErrors, ListFolderFoldersResponse, ListFolderFoldersResponses, ListNotesByDeckData, ListNotesByDeckError, ListNotesByDeckErrors, ListNotesByDeckResponse, ListNotesByDeckResponses, ListWorkspaceDecksData, ListWorkspaceDecksError, ListWorkspaceDecksErrors, ListWorkspaceDecksResponse, ListWorkspaceDecksResponses, ListWorkspaceFoldersData, ListWorkspaceFoldersError, ListWorkspaceFoldersErrors, ListWorkspaceFoldersResponse, ListWorkspaceFoldersResponses, ListWorkspacesData, ListWorkspacesError, ListWorkspacesErrors, ListWorkspacesResponse, ListWorkspacesResponses, MessageDomainError, NoteById, NoteDetail, NoteDraft, NoteId, NoteKind, NoteListItem, NoteRef, Notes, NoteSearchResult, NoteSearchResultGroup, NotesNoteDetail, NoteSortField, NoteSortField2, NoteStatus, PracticeReviewSession, ResetSettings, ResetSettingsData, ResetSettingsError, ResetSettingsErrors, ResetSettingsResponse, ResetSettingsResponses, RestoreTrashItem, RestoreTrashItemData, RestoreTrashItemError, RestoreTrashItemErrors, RestoreTrashItemResponse, RestoreTrashItemResponses, ReviewById, ReviewCard, ReviewCardOrNull, ReviewGrade, ReviewId, ReviewReviewCard, ReviewReviewSession, ReviewSession, ReviewStartResult, ReviewUnavailable, ReviewUnavailableReason, RuntimeFormFactor, RuntimeKind, RuntimeProfile, Search, SearchContentData, SearchContentError, SearchContentErrors, SearchContentResponse, SearchContentResponses, SearchRequest, SearchResultGroup, SearchResultLocationPath, SearchScope, SearchSearchResultGroup, SetActiveWorkspaceData, SetActiveWorkspaceError, SetActiveWorkspaceErrors, SetActiveWorkspaceRequest, SetActiveWorkspaceResponse, SetActiveWorkspaceResponses, Settings, Settings2, SettingsDefaults, SettingsNewCardsOrder, SettingsSettings, SortDirection, SortDirection2, StartReviewSessionData, StartReviewSessionError, StartReviewSessionErrors, StartReviewSessionResponse, StartReviewSessionResponses, Trash, TrashItem, TrashItemById, TrashKind, TrashState, TrashTrashState, UpdateDeckData, UpdateDeckError, UpdateDeckErrors, UpdateDeckResponse, UpdateDeckResponses, UpdateFolderData, UpdateFolderError, UpdateFolderErrors, UpdateFolderResponse, UpdateFolderResponses, UpdateNoteData, UpdateNoteError, UpdateNoteErrors, UpdateNoteResponse, UpdateNoteResponses, UpdateSettingsData, UpdateSettingsError, UpdateSettingsErrors, UpdateSettingsResponse, UpdateSettingsResponses, UpdateWorkspaceData, UpdateWorkspaceError, UpdateWorkspaceErrors, UpdateWorkspaceResponse, UpdateWorkspaceResponses, ValidationDomainError, ValidationIssue, VisualIconName, Workspace, WorkspaceById, WorkspaceDecks, WorkspaceDraft, WorkspaceFolders, WorkspaceId, WorkspaceListResult, Workspaces, WorkspacesActiveWorkspace, WorkspaceSearchScope, WorkspacesWorkspace } from './types.gen'; +export type { ActiveWorkspace, ActiveWorkspace2, BasicNote, BasicNoteDraft, BasicNoteEditor, BasicReviewCard, Bootstrap, BootstrapBootstrapResult, BootstrapData, BootstrapError, BootstrapErrors, BootstrapResponse, BootstrapResponses, BootstrapResult, CardId, ClientOptions, ClozeNote, ClozeNoteCard, ClozeNoteDraft, ClozeNoteEditor, ClozeReviewCard, ComponentsCardId, ComponentsDeckId, ComponentsDeckSortField, ComponentsFolderId, ComponentsFolderSortField, ComponentsItemId, ComponentsNoteId, ComponentsNoteSortField, ComponentsProblemDetails, ComponentsReviewId, ComponentsSortDirection, ComponentsWorkspaceId, CreateDeckData, CreateDeckError, CreateDeckErrors, CreateDeckResponse, CreateDeckResponses, CreateFolderData, CreateFolderError, CreateFolderErrors, CreateFolderResponse, CreateFolderResponses, CreateNoteData, CreateNoteError, CreateNoteErrors, CreateNoteResponse, CreateNoteResponses, CreateWorkspaceData, CreateWorkspaceError, CreateWorkspaceErrors, CreateWorkspaceResponse, CreateWorkspaceResponses, DateTime, Deck, DeckById, DeckDraft, DeckId, DeckNotes, DeckReviews, Decks, DecksDeck, DeckSearchResult, DeckSearchResultGroup, DeckSearchScope, DeckSortField, DeckSortField2, DeleteDeckData, DeleteDeckError, DeleteDeckErrors, DeleteDeckResponse, DeleteDeckResponses, DeleteFolderData, DeleteFolderError, DeleteFolderErrors, DeleteFolderResponse, DeleteFolderResponses, DeleteNoteData, DeleteNoteError, DeleteNoteErrors, DeleteNoteResponse, DeleteNoteResponses, DeleteTrashItemData, DeleteTrashItemError, DeleteTrashItemErrors, DeleteTrashItemResponse, DeleteTrashItemResponses, DeleteWorkspaceData, DeleteWorkspaceError, DeleteWorkspaceErrors, DeleteWorkspaceResponse, DeleteWorkspaceResponses, DeleteWorkspaceResult, DueReviewSession, DueReviewSessionStatus, EmptyTrashData, EmptyTrashError, EmptyTrashErrors, EmptyTrashResponse, EmptyTrashResponses, Folder, FolderById, FolderDecks, FolderDraft, FolderFolders, FolderId, FolderPath, FolderPath2, Folders, FolderSearchResult, FolderSearchResultGroup, FolderSearchScope, FoldersFolder, FolderSortField, FolderSortField2, GetActiveWorkspaceData, GetActiveWorkspaceError, GetActiveWorkspaceErrors, GetActiveWorkspaceResponse, GetActiveWorkspaceResponses, GetDeckData, GetDeckError, GetDeckErrors, GetDeckResponse, GetDeckResponses, GetDefaultSettingsData, GetDefaultSettingsError, GetDefaultSettingsErrors, GetDefaultSettingsResponse, GetDefaultSettingsResponses, GetFolderData, GetFolderError, GetFolderErrors, GetFolderPathData, GetFolderPathError, GetFolderPathErrors, GetFolderPathResponse, GetFolderPathResponses, GetFolderResponse, GetFolderResponses, GetNoteData, GetNoteError, GetNoteErrors, GetNoteResponse, GetNoteResponses, GetReviewSessionData, GetReviewSessionError, GetReviewSessionErrors, GetReviewSessionResponse, GetReviewSessionResponses, GetSettingsData, GetSettingsError, GetSettingsErrors, GetSettingsResponse, GetSettingsResponses, GetTrashData, GetTrashError, GetTrashErrors, GetTrashResponse, GetTrashResponses, GetWorkspaceData, GetWorkspaceError, GetWorkspaceErrors, GetWorkspaceResponse, GetWorkspaceResponses, GradeReviewCardRequest, GradeReviewSessionCard, GradeReviewSessionCardData, GradeReviewSessionCardError, GradeReviewSessionCardErrors, GradeReviewSessionCardResponse, GradeReviewSessionCardResponses, Id, ItemId, ListFolderDecksData, ListFolderDecksError, ListFolderDecksErrors, ListFolderDecksResponse, ListFolderDecksResponses, ListFolderFoldersData, ListFolderFoldersError, ListFolderFoldersErrors, ListFolderFoldersResponse, ListFolderFoldersResponses, ListNotesByDeckData, ListNotesByDeckError, ListNotesByDeckErrors, ListNotesByDeckResponse, ListNotesByDeckResponses, ListWorkspaceDecksData, ListWorkspaceDecksError, ListWorkspaceDecksErrors, ListWorkspaceDecksResponse, ListWorkspaceDecksResponses, ListWorkspaceFoldersData, ListWorkspaceFoldersError, ListWorkspaceFoldersErrors, ListWorkspaceFoldersResponse, ListWorkspaceFoldersResponses, ListWorkspacesData, ListWorkspacesError, ListWorkspacesErrors, ListWorkspacesResponse, ListWorkspacesResponses, MessageProblemDetails, NoteById, NoteDetail, NoteDraft, NoteId, NoteKind, NoteListItem, NoteRef, Notes, NoteSearchResult, NoteSearchResultGroup, NotesNoteDetail, NoteSortField, NoteSortField2, NoteStatus, PracticeReviewSession, ProblemDetails, ResetSettings, ResetSettingsData, ResetSettingsError, ResetSettingsErrors, ResetSettingsResponse, ResetSettingsResponses, RestoreTrashItem, RestoreTrashItemData, RestoreTrashItemError, RestoreTrashItemErrors, RestoreTrashItemResponse, RestoreTrashItemResponses, ReviewById, ReviewCard, ReviewCardOrNull, ReviewGrade, ReviewId, ReviewReviewCard, ReviewReviewSession, ReviewSession, ReviewStartResult, ReviewUnavailable, ReviewUnavailableReason, RuntimeFormFactor, RuntimeKind, RuntimeProfile, Search, SearchContentData, SearchContentError, SearchContentErrors, SearchContentResponse, SearchContentResponses, SearchRequest, SearchResultGroup, SearchResultLocationPath, SearchScope, SearchSearchResultGroup, SetActiveWorkspaceData, SetActiveWorkspaceError, SetActiveWorkspaceErrors, SetActiveWorkspaceRequest, SetActiveWorkspaceResponse, SetActiveWorkspaceResponses, Settings, Settings2, SettingsDefaults, SettingsNewCardsOrder, SettingsSettings, SortDirection, SortDirection2, StartReviewSessionData, StartReviewSessionError, StartReviewSessionErrors, StartReviewSessionResponse, StartReviewSessionResponses, Trash, TrashItem, TrashItemById, TrashKind, TrashState, TrashTrashState, UpdateDeckData, UpdateDeckError, UpdateDeckErrors, UpdateDeckResponse, UpdateDeckResponses, UpdateFolderData, UpdateFolderError, UpdateFolderErrors, UpdateFolderResponse, UpdateFolderResponses, UpdateNoteData, UpdateNoteError, UpdateNoteErrors, UpdateNoteResponse, UpdateNoteResponses, UpdateSettingsData, UpdateSettingsError, UpdateSettingsErrors, UpdateSettingsResponse, UpdateSettingsResponses, UpdateWorkspaceData, UpdateWorkspaceError, UpdateWorkspaceErrors, UpdateWorkspaceResponse, UpdateWorkspaceResponses, ValidationIssue, ValidationProblemDetails, VisualIconName, Workspace, WorkspaceById, WorkspaceDecks, WorkspaceDraft, WorkspaceFolders, WorkspaceId, WorkspaceListResult, Workspaces, WorkspacesActiveWorkspace, WorkspaceSearchScope, WorkspacesWorkspace } from './types.gen'; diff --git a/ui/src/shared/services/api/generated/clear-api/types.gen.ts b/ui/src/shared/services/api/generated/clear-api/types.gen.ts index 1f42330..7fa8ff2 100644 --- a/ui/src/shared/services/api/generated/clear-api/types.gen.ts +++ b/ui/src/shared/services/api/generated/clear-api/types.gen.ts @@ -10,12 +10,12 @@ export type BootstrapResult = BootstrapBootstrapResult; export type Deck = DecksDeck; -export type DomainError = ComponentsDomainError; - export type Folder = FoldersFolder; export type NoteDetail = NotesNoteDetail; +export type ProblemDetails = ComponentsProblemDetails; + export type ReviewCard = ReviewReviewCard; export type ReviewSession = ReviewReviewSession; @@ -449,19 +449,15 @@ export type DateTime = string; export type DeckSortField = 'dueToday' | 'title' | 'updated'; -export type ComponentsDomainError = ({ - type: 'validation'; -} & ValidationDomainError) | ({ - type: 'conflict' | 'forbidden' | 'not_found' | 'offline' | 'timeout' | 'unauthorized' | 'unexpected' | 'unavailable'; -} & MessageDomainError); - export type FolderSortField = 'title' | 'updated'; export type Id = string; -export type MessageDomainError = { - type: 'conflict' | 'forbidden' | 'not_found' | 'offline' | 'timeout' | 'unauthorized' | 'unexpected' | 'unavailable'; - message: string; +export type MessageProblemDetails = { + type: '/problems/bad-request' | '/problems/conflict' | '/problems/forbidden' | '/problems/not-found' | '/problems/timeout' | '/problems/unauthorized' | '/problems/unexpected' | '/problems/unavailable'; + title: string; + status: number; + detail?: string; retryable: boolean; entity?: string; entityId?: string; @@ -469,13 +465,13 @@ export type MessageDomainError = { export type NoteSortField = 'title' | 'updated'; -export type SortDirection = 'asc' | 'desc'; +export type ComponentsProblemDetails = ({ + type: '/problems/validation'; +} & ValidationProblemDetails) | ({ + type: '/problems/bad-request' | '/problems/conflict' | '/problems/forbidden' | '/problems/not-found' | '/problems/timeout' | '/problems/unauthorized' | '/problems/unexpected' | '/problems/unavailable'; +} & MessageProblemDetails); -export type ValidationDomainError = { - type: 'validation'; - issues: Array; - retryable: false; -}; +export type SortDirection = 'asc' | 'desc'; export type ValidationIssue = { path?: Array; @@ -485,6 +481,15 @@ export type ValidationIssue = { }; }; +export type ValidationProblemDetails = { + type: '/problems/validation'; + title: string; + status: 422; + detail?: string; + issues: Array; + retryable: false; +}; + /** * Lucide icon name used by the UI visual picker. */ @@ -611,15 +616,15 @@ export type BootstrapErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; /** * The service is temporarily unavailable. */ - 503: ComponentsDomainError; + 503: ComponentsProblemDetails; }; export type BootstrapError = BootstrapErrors[keyof BootstrapErrors]; @@ -644,11 +649,11 @@ export type ListWorkspacesErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ListWorkspacesError = ListWorkspacesErrors[keyof ListWorkspacesErrors]; @@ -673,19 +678,19 @@ export type CreateWorkspaceErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type CreateWorkspaceError = CreateWorkspaceErrors[keyof CreateWorkspaceErrors]; @@ -710,11 +715,11 @@ export type GetActiveWorkspaceErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetActiveWorkspaceError = GetActiveWorkspaceErrors[keyof GetActiveWorkspaceErrors]; @@ -739,19 +744,19 @@ export type SetActiveWorkspaceErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type SetActiveWorkspaceError = SetActiveWorkspaceErrors[keyof SetActiveWorkspaceErrors]; @@ -781,15 +786,15 @@ export type DeleteWorkspaceErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type DeleteWorkspaceError = DeleteWorkspaceErrors[keyof DeleteWorkspaceErrors]; @@ -819,11 +824,11 @@ export type GetWorkspaceErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetWorkspaceError = GetWorkspaceErrors[keyof GetWorkspaceErrors]; @@ -853,23 +858,23 @@ export type UpdateWorkspaceErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type UpdateWorkspaceError = UpdateWorkspaceErrors[keyof UpdateWorkspaceErrors]; @@ -908,11 +913,11 @@ export type ListWorkspaceFoldersErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ListWorkspaceFoldersError = ListWorkspaceFoldersErrors[keyof ListWorkspaceFoldersErrors]; @@ -951,11 +956,11 @@ export type ListWorkspaceDecksErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ListWorkspaceDecksError = ListWorkspaceDecksErrors[keyof ListWorkspaceDecksErrors]; @@ -980,23 +985,23 @@ export type CreateFolderErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type CreateFolderError = CreateFolderErrors[keyof CreateFolderErrors]; @@ -1026,15 +1031,15 @@ export type DeleteFolderErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type DeleteFolderError = DeleteFolderErrors[keyof DeleteFolderErrors]; @@ -1064,11 +1069,11 @@ export type GetFolderErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetFolderError = GetFolderErrors[keyof GetFolderErrors]; @@ -1098,23 +1103,23 @@ export type UpdateFolderErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type UpdateFolderError = UpdateFolderErrors[keyof UpdateFolderErrors]; @@ -1153,11 +1158,11 @@ export type ListFolderFoldersErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ListFolderFoldersError = ListFolderFoldersErrors[keyof ListFolderFoldersErrors]; @@ -1196,11 +1201,11 @@ export type ListFolderDecksErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ListFolderDecksError = ListFolderDecksErrors[keyof ListFolderDecksErrors]; @@ -1230,11 +1235,11 @@ export type GetFolderPathErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetFolderPathError = GetFolderPathErrors[keyof GetFolderPathErrors]; @@ -1259,23 +1264,23 @@ export type CreateDeckErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type CreateDeckError = CreateDeckErrors[keyof CreateDeckErrors]; @@ -1305,15 +1310,15 @@ export type DeleteDeckErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type DeleteDeckError = DeleteDeckErrors[keyof DeleteDeckErrors]; @@ -1343,11 +1348,11 @@ export type GetDeckErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetDeckError = GetDeckErrors[keyof GetDeckErrors]; @@ -1377,23 +1382,23 @@ export type UpdateDeckErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type UpdateDeckError = UpdateDeckErrors[keyof UpdateDeckErrors]; @@ -1432,11 +1437,11 @@ export type ListNotesByDeckErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ListNotesByDeckError = ListNotesByDeckErrors[keyof ListNotesByDeckErrors]; @@ -1466,11 +1471,11 @@ export type StartReviewSessionErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type StartReviewSessionError = StartReviewSessionErrors[keyof StartReviewSessionErrors]; @@ -1500,11 +1505,11 @@ export type GetReviewSessionErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetReviewSessionError = GetReviewSessionErrors[keyof GetReviewSessionErrors]; @@ -1529,23 +1534,23 @@ export type CreateNoteErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type CreateNoteError = CreateNoteErrors[keyof CreateNoteErrors]; @@ -1575,15 +1580,15 @@ export type DeleteNoteErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type DeleteNoteError = DeleteNoteErrors[keyof DeleteNoteErrors]; @@ -1613,11 +1618,11 @@ export type GetNoteErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetNoteError = GetNoteErrors[keyof GetNoteErrors]; @@ -1647,23 +1652,23 @@ export type UpdateNoteErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type UpdateNoteError = UpdateNoteErrors[keyof UpdateNoteErrors]; @@ -1697,23 +1702,23 @@ export type GradeReviewSessionCardErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GradeReviewSessionCardError = GradeReviewSessionCardErrors[keyof GradeReviewSessionCardErrors]; @@ -1738,11 +1743,11 @@ export type GetSettingsErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetSettingsError = GetSettingsErrors[keyof GetSettingsErrors]; @@ -1767,15 +1772,15 @@ export type UpdateSettingsErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type UpdateSettingsError = UpdateSettingsErrors[keyof UpdateSettingsErrors]; @@ -1800,11 +1805,11 @@ export type GetDefaultSettingsErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetDefaultSettingsError = GetDefaultSettingsErrors[keyof GetDefaultSettingsErrors]; @@ -1829,11 +1834,11 @@ export type ResetSettingsErrors = { /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type ResetSettingsError = ResetSettingsErrors[keyof ResetSettingsErrors]; @@ -1858,11 +1863,11 @@ export type EmptyTrashErrors = { /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type EmptyTrashError = EmptyTrashErrors[keyof EmptyTrashErrors]; @@ -1887,11 +1892,11 @@ export type GetTrashErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type GetTrashError = GetTrashErrors[keyof GetTrashErrors]; @@ -1921,15 +1926,15 @@ export type RestoreTrashItemErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type RestoreTrashItemError = RestoreTrashItemErrors[keyof RestoreTrashItemErrors]; @@ -1959,15 +1964,15 @@ export type DeleteTrashItemErrors = { /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request conflicts with current resource state. */ - 409: ComponentsDomainError; + 409: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type DeleteTrashItemError = DeleteTrashItemErrors[keyof DeleteTrashItemErrors]; @@ -1992,19 +1997,19 @@ export type SearchContentErrors = { /** * The request was malformed. */ - 400: ComponentsDomainError; + 400: ComponentsProblemDetails; /** * The requested resource was not found. */ - 404: ComponentsDomainError; + 404: ComponentsProblemDetails; /** * The request failed domain validation. */ - 422: ComponentsDomainError; + 422: ComponentsProblemDetails; /** * An unexpected error occurred. */ - 500: ComponentsDomainError; + 500: ComponentsProblemDetails; }; export type SearchContentError = SearchContentErrors[keyof SearchContentErrors]; diff --git a/ui/src/shared/services/api/generated/clear-api/zod.gen.ts b/ui/src/shared/services/api/generated/clear-api/zod.gen.ts index beb0c56..e112093 100644 --- a/ui/src/shared/services/api/generated/clear-api/zod.gen.ts +++ b/ui/src/shared/services/api/generated/clear-api/zod.gen.ts @@ -388,18 +388,20 @@ export const zSetActiveWorkspaceRequest = z.object({ workspaceId: zId }); -export const zMessageDomainError = z.object({ +export const zMessageProblemDetails = z.object({ type: z.enum([ - 'conflict', - 'forbidden', - 'not_found', - 'offline', - 'timeout', - 'unauthorized', - 'unexpected', - 'unavailable' + '/problems/bad-request', + '/problems/conflict', + '/problems/forbidden', + '/problems/not-found', + '/problems/timeout', + '/problems/unauthorized', + '/problems/unexpected', + '/problems/unavailable' ]), - message: z.string().min(1), + title: z.string().min(1), + status: z.int().gte(400).lte(599), + detail: z.string().min(1).optional(), retryable: z.boolean(), entity: z.string().optional(), entityId: z.string().optional() @@ -415,31 +417,34 @@ export const zValidationIssue = z.object({ params: z.record(z.string(), z.unknown()).optional() }); -export const zValidationDomainError = z.object({ - type: z.enum(['validation']), +export const zValidationProblemDetails = z.object({ + type: z.enum(['/problems/validation']), + title: z.string().min(1), + status: z.literal(422), + detail: z.string().min(1).optional(), issues: z.array(zValidationIssue), retryable: z.literal(false) }); -export const zComponentsDomainError = z.union([ +export const zComponentsProblemDetails = z.union([ z.object({ - type: z.literal('validation') - }).and(zValidationDomainError), + type: z.literal('/problems/validation') + }).and(zValidationProblemDetails), z.object({ type: z.union([ - z.literal('conflict'), - z.literal('forbidden'), - z.literal('not_found'), - z.literal('offline'), - z.literal('timeout'), - z.literal('unauthorized'), - z.literal('unexpected'), - z.literal('unavailable') + z.literal('/problems/bad-request'), + z.literal('/problems/conflict'), + z.literal('/problems/forbidden'), + z.literal('/problems/not-found'), + z.literal('/problems/timeout'), + z.literal('/problems/unauthorized'), + z.literal('/problems/unexpected'), + z.literal('/problems/unavailable') ]) - }).and(zMessageDomainError) + }).and(zMessageProblemDetails) ]); -export const zDomainError = zComponentsDomainError; +export const zProblemDetails = zComponentsProblemDetails; /** * Lucide icon name used by the UI visual picker.