{"version":3,"file":"describe-api-error.cjs","names":[],"sources":["../../src/http/describe-api-error.ts"],"sourcesContent":["// The last mile of error handling: a typed error is what code reads, a sentence\n// is what a person reads, and every app was writing the funnel between the two.\n// The case everyone forgets is the request that never reached the server, which\n// without special handling renders as the nonsense \"erro 0\".\n\nimport { isApiError, syntheticDetail } from \"./errors\";\n\n/** The fixed sentences {@link describeApiError} may need. */\nexport interface ApiErrorStrings {\n    /** Shown when the request never reached the server. */\n    offline: string;\n    /**\n     * Shown when the backend rejected the payload field by field.\n     *\n     * The per-field messages are on `error.fields`, to be attached to the inputs\n     * themselves; this sentence is what the toast says.\n     *\n     * It does not apply to the one rejection that named a single field with a\n     * finished sentence — there the server's own `detail` is shown instead, since\n     * it is the same string `fields` carries. Pass `useDetail: false` to force\n     * this sentence in that case too.\n     */\n    validation: string;\n}\n\n/**\n * PT-BR defaults, used when no strings are supplied and no catalog answers.\n *\n * The SDK's copy is pt-BR everywhere else (`FilterBar`, `DataTable`, `Chat`), so\n * the default here matches rather than introducing an English string that only\n * shows up on a network failure.\n */\nexport const DEFAULT_API_ERROR_STRINGS: ApiErrorStrings = {\n    offline: \"Sem conexão com o servidor. Verifique sua internet e tente de novo.\",\n    validation: \"Confira os campos destacados e tente de novo.\",\n};\n\n/**\n * Translation key the {@link useDescribeApiError} hook looks up.\n *\n * A catalog that does not define it falls back to\n * {@link DEFAULT_API_ERROR_STRINGS}, because `t` returns the key itself when the\n * lookup misses and printing `tempest.error.offline` at the user would be worse\n * than printing pt-BR at them.\n */\nexport const API_ERROR_OFFLINE_KEY = \"tempest.error.offline\";\n\n/**\n * Translation key for the validation sentence, looked up the same way as\n * {@link API_ERROR_OFFLINE_KEY}.\n */\nexport const API_ERROR_VALIDATION_KEY = \"tempest.error.validation\";\n\n/**\n * Everything {@link describeApiError} accepts beyond the error and the fallback.\n *\n * Extends the fixed sentences rather than sitting beside them, so a caller that\n * already passed `{ offline, validation }` keeps compiling untouched.\n */\nexport interface DescribeApiErrorOptions extends Partial<ApiErrorStrings> {\n    /**\n     * Maps the backend's programmatic `code` to a sentence in your language.\n     *\n     * The client already surfaces `code` on `ApiError`, but without this every\n     * app writes the same `switch` over it. A hit here wins over every other\n     * step: it is the only sentence written for that exact case, by someone who\n     * knew both the backend contract and the screen it lands on.\n     */\n    codes?: Readonly<Record<string, string>>;\n    /**\n     * Whether the backend's `detail` may be shown when no `code` matched.\n     * Default `true`.\n     *\n     * Set it to `false` when `detail` is written for developers rather than\n     * users, or when it could echo internals — the result is then always either\n     * a sentence you wrote or the fallback.\n     */\n    useDetail?: boolean;\n}\n\n/**\n * Whether the browser currently reports itself as offline.\n *\n * `fetch` rejects a network failure with a plain `TypeError` whose message\n * differs per browser (\"Failed to fetch\", \"NetworkError when attempting to fetch\n * resource.\", \"Load failed\"), so sniffing the message is not portable. The online\n * flag is, and it is the signal that matters for the sentence being chosen.\n *\n * @returns `true` only when the environment positively says it is offline.\n */\nfunction browserIsOffline(): boolean {\n    return typeof navigator !== \"undefined\" && navigator.onLine === false;\n}\n\n/**\n * Whether `fields` is the flattened single-field envelope, carrying `detail`.\n *\n * The check is identity rather than shape-sniffing. `collectFields` has exactly\n * two sources: FastAPI's `detail` **list**, whose entries are the per-issue\n * messages, and the flattened envelope, whose one entry is built from the same\n * string that becomes `ApiError.detail`. So one entry equal to `detail` can only\n * have come from the second, and returning `detail` there shows the string the\n * form is already about to attach to that input.\n *\n * @param fields - The error's field messages.\n * @param detail - `error.detail`, already trimmed.\n * @param status - The HTTP status, to recognise the synthetic `Erro <status>`\n *     that stands in when the body carried no message at all.\n * @returns Whether `detail` is safe to show as the sentence.\n */\nfunction singleFieldSentence(\n    fields: Record<string, string>,\n    detail: string,\n    status: number,\n): boolean {\n    if (detail === \"\" || detail === syntheticDetail(status)) return false;\n    const messages = Object.values(fields);\n    return messages.length === 1 && messages[0].trim() === detail;\n}\n\n/**\n * Turn any caught value into a sentence worth showing.\n *\n * The funnel, in order:\n *\n * 0. `codes[error.code]` — the sentence you wrote for that exact backend case.\n *    Checked first because nothing the funnel derives can beat it, and because a\n *    request that never landed carries no `code` for it to shadow.\n * 1. A request that never reached the server — `status === 0`, or a non-API\n *    error thrown while the browser reports itself offline — produces the\n *    offline sentence. This is the step apps skip, and skipping it renders\n *    \"erro 0\" or a raw `TypeError` at the user.\n * 2. A validation rejection — `error.fields` is set — produces the validation\n *    sentence, **not** `detail`. On a FastAPI `422` the `detail` line is assembled\n *    from the backend's field paths and the validator's own wording\n *    (`\"items.0.price: Input should be greater than 0\"`), which is right for a\n *    log and wrong for a person: it is half English in a pt-BR screen and it\n *    names internals. The per-field messages stay on `fields`, where a form can\n *    attach them to the inputs that failed.\n *\n *    **Unless the rejection named exactly one field and its message is\n *    `detail` itself**, in which case `detail` is returned. That is not a guess\n *    about the text: `collectFields` fills a single entry from `detail` only for\n *    the flattened envelope a `tempest-fastapi-sdk` backend sends, where the\n *    server wrote one finished sentence about one field (`\"CPF ou CNPJ\n *    inválido\"`). Returning it shows the same string that is already on\n *    `fields`, so nothing is invented and nothing is lost. The assembled FastAPI\n *    line never reaches this branch, because it comes from the `detail` **list**\n *    and its entries are the per-issue messages, not `detail`.\n *\n *    This is a fix, not a preference. Before `namedField` shipped in 0.54.0,\n *    `fields` was empty against that backend and the sentence reached the user\n *    through step 3; filling `fields` silently replaced it with \"Confira os\n *    campos destacados\", which presumes a screen that highlights fields — and\n *    no app highlighted anything on the day of the bump.\n *\n *    `useDetail: false` still suppresses it, and is the way to force the fixed\n *    sentence for a backend whose text is written for developers. `validation`\n *    does **not** override it, and that is on purpose: its own contract is the\n *    sentence for a payload rejected *field by field*, which one field carrying\n *    one finished sentence is not — and `useDescribeApiError` always passes\n *    `validation`, so treating it as an override would mean the branch never ran\n *    for any component, which is every caller that matters here.\n * 3. The backend's own `detail`, which is the most specific thing available and\n *    is already written for a person — unless `useDetail: false` says that text\n *    is for developers.\n * 4. `fallback`, with `(HTTP <status>)` appended when a status is known, so the\n *    screenshot in the support ticket carries the one fact a developer needs.\n *\n * Pure on purpose: it works in an interceptor, in a logger and anywhere outside\n * the React tree. {@link useDescribeApiError} is the same funnel with the\n * sentences resolved through `I18nProvider`.\n *\n * @example\n * catch (error) {\n *     toast(describeApiError(error, \"Não foi possível salvar o pedido\"));\n * }\n *\n * @example\n * catch (error) {\n *     toast(\n *         describeApiError(error, \"Não foi possível se candidatar\", {\n *             codes: {\n *                 SERVICE_FULL: \"Este serviço atingiu o limite de vagas.\",\n *                 CANDIDATE_ALREADY_EXISTS: \"Você já se candidatou a este serviço.\",\n *             },\n *             useDetail: false,\n *         }),\n *     );\n * }\n *\n * @param error - The caught value, of any shape.\n * @param fallback - What to say when the error carries nothing better.\n * @param options - A `codes` catalog, `useDetail`, and overrides for the fixed\n *     sentences.\n * @returns A sentence to show the user.\n */\nexport function describeApiError(\n    error: unknown,\n    fallback: string,\n    options?: DescribeApiErrorOptions,\n): string {\n    const offline = options?.offline ?? DEFAULT_API_ERROR_STRINGS.offline;\n\n    if (isApiError(error)) {\n        const mapped = error.code === undefined ? undefined : options?.codes?.[error.code];\n        if (mapped !== undefined) return mapped;\n        if (error.status === 0) return offline;\n        const detail = error.detail.trim();\n        if (error.fields && Object.keys(error.fields).length > 0) {\n            if (\n                options?.useDetail !== false &&\n                singleFieldSentence(error.fields, detail, error.status)\n            ) {\n                return detail;\n            }\n            return options?.validation ?? DEFAULT_API_ERROR_STRINGS.validation;\n        }\n        if (\n            options?.useDetail !== false &&\n            detail !== \"\" &&\n            detail !== syntheticDetail(error.status)\n        ) {\n            return detail;\n        }\n        return `${fallback} (HTTP ${error.status})`;\n    }\n\n    if (browserIsOffline()) return offline;\n\n    return fallback;\n}\n"],"mappings":"gCAgCA,IAAa,EAA6C,CACtD,QAAS,sEACT,WAAY,+CAChB,EAUa,EAAwB,wBAMxB,EAA2B,2BAuCxC,SAAS,GAA4B,CACjC,OAAO,OAAO,UAAc,KAAe,UAAU,SAAW,EACpE,CAkBA,SAAS,EACL,EACA,EACA,EACO,CACP,GAAI,IAAW,IAAM,IAAW,EAAA,gBAAgB,CAAM,EAAG,MAAO,GAChE,IAAM,EAAW,OAAO,OAAO,CAAM,EACrC,OAAO,EAAS,SAAW,GAAK,EAAS,EAAE,CAAC,KAAK,IAAM,CAC3D,CA+EA,SAAgB,EACZ,EACA,EACA,EACM,CACN,IAAM,EAAU,GAAS,SAAW,EAA0B,QAE9D,GAAI,EAAA,WAAW,CAAK,EAAG,CACnB,IAAM,EAAS,EAAM,OAAS,IAAA,GAAY,IAAA,GAAY,GAAS,QAAQ,EAAM,MAC7E,GAAI,IAAW,IAAA,GAAW,OAAO,EACjC,GAAI,EAAM,SAAW,EAAG,OAAO,EAC/B,IAAM,EAAS,EAAM,OAAO,KAAK,EAiBjC,OAhBI,EAAM,QAAU,OAAO,KAAK,EAAM,MAAM,CAAC,CAAC,OAAS,EAE/C,GAAS,YAAc,IACvB,EAAoB,EAAM,OAAQ,EAAQ,EAAM,MAAM,EAE/C,EAEJ,GAAS,YAAc,EAA0B,WAGxD,GAAS,YAAc,IACvB,IAAW,IACX,IAAW,EAAA,gBAAgB,EAAM,MAAM,EAEhC,EAEJ,GAAG,EAAS,SAAS,EAAM,OAAO,EAC7C,CAIA,OAFI,EAAiB,EAAU,EAExB,CACX"}