diff --git a/src/lib/__tests__/xrpc-error.test.ts b/src/lib/__tests__/xrpc-error.test.ts new file mode 100644 index 0000000000..5c09fb4f08 --- /dev/null +++ b/src/lib/__tests__/xrpc-error.test.ts @@ -0,0 +1,91 @@ +import { + getMain, + type Procedure, + type Query, + XrpcInternalError, + XrpcResponseError, +} from '@atproto/lex' +import {describe, expect, it} from '@jest/globals' + +import {app, com} from '#/lexicons' +import {matchXrpcError} from '../xrpc-error' + +const createAccount = com.atproto.server.createAccount +const getTrends = app.bsky.unspecced.getTrends + +/** + * An `XrpcResponseError` as a lex `Client` would construct it: the method + * schema it was thrown for, plus the server's error response and its parsed + * payload. + */ +function responseError(method: Procedure | Query, error: string, status = 400) { + return new XrpcResponseError( + method, + new Response(JSON.stringify({error}), { + status, + headers: {'content-type': 'application/json'}, + }), + {encoding: 'application/json', body: {error}}, + ) +} + +describe('matchXrpcError', () => { + it('returns a code declared by the method', () => { + const e = responseError(getMain(createAccount), 'InvalidHandle') + expect(matchXrpcError(e, createAccount)).toBe('InvalidHandle') + }) + + it('accepts the .main schema as well as the namespace', () => { + const e = responseError(getMain(createAccount), 'InvalidInviteCode') + expect(matchXrpcError(e, createAccount.main)).toBe('InvalidInviteCode') + }) + + it('returns undefined for a code the method does not declare', () => { + const e = responseError(getMain(createAccount), 'RateLimitExceeded') + expect(matchXrpcError(e, createAccount)).toBeUndefined() + }) + + it('returns undefined for a method that declares no errors at all', () => { + const e = responseError(getMain(getTrends), 'InvalidHandle') + expect(matchXrpcError(e, getTrends)).toBeUndefined() + }) + + it('does not match a declared code thrown for a different method', () => { + /* + * `InvalidHandle` is declared by createAccount but this error came from a + * getTrends call, so scoping must reject it. + */ + const e = responseError(getMain(getTrends), 'InvalidHandle') + expect(matchXrpcError(e, createAccount)).toBeUndefined() + }) + + it('returns undefined for a lex error carrying no server code', () => { + const e = new XrpcInternalError(getMain(createAccount), 'boom') + expect(matchXrpcError(e, createAccount)).toBeUndefined() + }) + + it('returns undefined for non-lex errors and non-errors', () => { + expect(matchXrpcError(new Error('InvalidHandle'), createAccount)).toBe( + undefined, + ) + expect(matchXrpcError('InvalidHandle', createAccount)).toBeUndefined() + expect(matchXrpcError(undefined, createAccount)).toBeUndefined() + }) + + it('narrows the result to the declared-errors union', () => { + const e = responseError(getMain(createAccount), 'InvalidHandle') + const code = matchXrpcError(e, createAccount) + + /* + * A misspelled or undeclared code is not comparable to the narrowed union, + * which is what makes a typo'd `switch` case a compile error (TS2678 / + * TS2367) rather than a branch that never runs. + */ + // @ts-expect-error 'InvalidHandel' is not a declared createAccount error + expect(code === 'InvalidHandel').toBe(false) + // @ts-expect-error 'RateLimitExceeded' is not declared by createAccount + expect(code === 'RateLimitExceeded').toBe(false) + + expect(code === 'UnsupportedDomain').toBe(false) + }) +}) diff --git a/src/lib/xrpc-error.ts b/src/lib/xrpc-error.ts new file mode 100644 index 0000000000..5ee89ca0fc --- /dev/null +++ b/src/lib/xrpc-error.ts @@ -0,0 +1,50 @@ +import { + getMain, + type InferMethodError, + type Main, + type Procedure, + type Query, + XrpcResponseError, +} from '@atproto/lex' + +/** + * The lexicon error code carried by `e`, narrowed to the errors DECLARED by + * `method`, or `undefined` when `e` is not such an error. + * + * `XrpcResponseError.error` is the open `LexErrorCode` union, so comparing it + * as a plain string lets a typo silently never match. Narrowing the return type + * to `InferMethodError` makes a `switch` over the result reject an + * undeclared or misspelled `case` at compile time: + * + * ```ts + * switch (matchXrpcError(e, com.atproto.server.createAccount)) { + * case 'InvalidHandle': + * ... + * } + * ``` + * + * Matching is scoped to `method`: `XrpcError` records the method schema it was + * thrown for, so a declared code arriving from a DIFFERENT call does not match. + * Undeclared codes, non-XRPC errors, and the internal/fetch lex errors (which + * carry no server error code) all return `undefined`. + * + * `method` accepts the same value passed to `client.call` - either the + * generated method namespace (`com.atproto.server.createAccount`) or its + * `.main` schema - via lex's `Main`. + */ +export function matchXrpcError( + e: unknown, + method: Main, +): InferMethodError | undefined { + if (!(e instanceof XrpcResponseError)) { + return undefined + } + const schema: Procedure | Query = getMain(method) + const thrownFor: Procedure | Query = e.method + if (thrownFor.nsid !== schema.nsid) { + return undefined + } + return schema.errors?.includes(e.error) + ? (e.error as InferMethodError) + : undefined +} diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts index 8acbd47908..9f0ce29bcf 100644 --- a/src/screens/Signup/state.ts +++ b/src/screens/Signup/state.ts @@ -1,7 +1,6 @@ import {createContext, useCallback, useContext} from 'react' import {LayoutAnimation} from 'react-native' import {type ComAtprotoServerDescribeServer} from '@atproto/api' -import {XrpcResponseError} from '@atproto/lex' import {useLingui} from '@lingui/react/macro' import * as EmailValidator from 'email-validator' @@ -9,9 +8,11 @@ import {DEFAULT_SERVICE} from '#/lib/constants' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' import {getAge} from '#/lib/strings/time' +import {matchXrpcError} from '#/lib/xrpc-error' import {useSessionApi} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import {type AnalyticsContextType, useAnalytics} from '#/analytics' +import {com} from '#/lexicons' export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -258,34 +259,13 @@ export const useSignupContext = () => useContext(SignupContext) * failure is unexpected and should be reported to Sentry. */ function classifyExpectedSignupError(e: unknown): string | undefined { - /* - * TODO: `XrpcResponseError.error` is the open `LexErrorCode` union, so these - * codes are compared as plain strings and a typo silently never matches. - * Once the generated lexicons land, replace this (and every multi-code error - * site) with a shared helper that narrows against the method schema: - * - * function matchXrpcError( - * e: unknown, - * method: Main, - * ): InferMethodError | undefined - * - * switch (matchXrpcError(e, com.atproto.server.createAccount)) { - * case 'InvalidHandle': ... - * } - * - * The return type is the method's declared-errors union, so a typo'd case is - * a compile error, and undeclared codes fall through to `undefined`. The - * helper should also match `e.method.nsid` so a declared code from a - * different call cannot match, mirroring the old per-method error classes. - */ - if (e instanceof XrpcResponseError) { - switch (e.error) { - case 'InvalidHandle': - case 'HandleNotAvailable': - case 'InvalidPassword': - case 'UnsupportedDomain': - return e.error - } + const code = matchXrpcError(e, com.atproto.server.createAccount) + switch (code) { + case 'InvalidHandle': + case 'HandleNotAvailable': + case 'InvalidPassword': + case 'UnsupportedDomain': + return code } /* the server sends no typed error for this case */ if (String(e).includes('Email already taken')) return 'EmailTaken' @@ -376,7 +356,10 @@ export function useSubmitSignup() { } catch (err) { const e = err as Error let errMsg = e.toString() - if (e instanceof XrpcResponseError && e.error === 'InvalidInviteCode') { + if ( + matchXrpcError(e, com.atproto.server.createAccount) === + 'InvalidInviteCode' + ) { dispatch({ type: 'setError', value: l`Invite code not accepted. Check that you input it correctly and try again.`,