diff --git a/.changeset/tricky-lamps-pull.md b/.changeset/tricky-lamps-pull.md new file mode 100644 index 0000000..8fd8118 --- /dev/null +++ b/.changeset/tricky-lamps-pull.md @@ -0,0 +1,6 @@ +--- +"@fessional/razor-common": patch +"@fessional/razor-mobile": patch +--- + +✨ handle 401 return false dataresult #132 diff --git a/layers/common/composables/UseApiRoute.ts b/layers/common/composables/UseApiRoute.ts index f6e729e..ff1142c 100644 --- a/layers/common/composables/UseApiRoute.ts +++ b/layers/common/composables/UseApiRoute.ts @@ -1,24 +1,40 @@ -import { useEventBus, type EventBusKey, type UseEventBusReturn } from '@vueuse/core'; +import { useEventBus, type EventBusKey } from '@vueuse/core'; import { FetchError } from 'ofetch'; // import type { FetchOptions } from 'ofetch'; type ApiRouteOptions = NonNullable[1]>; -type ApiRouteAuthEvent = { +export type ApiRouteAuthEvent = { status: number; session?: string; headers?: Record; }; +/** + * https://github.com/unjs/ofetch/blob/main/src/fetch.ts + * handle the event and return + * - null - nop + * - non-null - to set the response._data, options.ignoreResponseError=true + * - FetchError - to set context.error, get it from error.cause + */ +export type ApiRouteAuthHandle = (event: ApiRouteAuthEvent) => SafeAny | FetchError; + export const apiRouteAuthEventKey: EventBusKey = Symbol('apiRouteResponseEventKey'); export const apiRouteAuthEventBus = useEventBus(apiRouteAuthEventKey); +/** + * emit event by apiRouteAuthEventBus, handle 401 to return `{success:false}` + */ +export const apiRouteAuthEmitter: ApiRouteAuthHandle = (evt) => { + apiRouteAuthEventBus.emit(evt); + return evt.status === 401 ? { success: false } : null; +}; /** * construct a onResponse by eventBus and listen status or header - * @param eventBus the event bus - * @param statusOrHeader status or header(case insensitive) + * @param statusOrHeader status or header(case insensitive), `[401]` as default. + * @param handler the event bus, `apiRouteAuthEmitter` as default. */ -export function apiRouteEmitOptions(statusOrHeader: (number | string)[] = [401, 403], eventBus: UseEventBusReturn = apiRouteAuthEventBus): ApiRouteOptions { +export function apiRouteAuthOptions(statusOrHeader: (number | string)[] = [401], handler: ApiRouteAuthHandle = apiRouteAuthEmitter): ApiRouteOptions { const status: number[] = []; const header: string[] = []; for (const k of statusOrHeader) { @@ -31,9 +47,9 @@ export function apiRouteEmitOptions(statusOrHeader: (number | string)[] = [401, } return { - onResponse: ({ response }) => { - const evt: ApiRouteAuthEvent = { status: response.status }; - const hds = response.headers; + onResponse: (ctx) => { + const evt: ApiRouteAuthEvent = { status: ctx.response.status }; + const hds = ctx.response.headers; const sn = hds.get('session'); if (sn) evt.session = sn; @@ -47,7 +63,14 @@ export function apiRouteEmitOptions(statusOrHeader: (number | string)[] = [401, } if (status.includes(evt.status) || evt.session != null || evt.headers != null) { - eventBus.emit(evt); + const rst = handler(evt); + if (rst instanceof FetchError) { + ctx.error = rst; + } + else if (rst != null) { + ctx.options.ignoreResponseError = true; + ctx.response._data = rst; + } } }, }; diff --git a/layers/common/tests/UseApiRoute.test.ts b/layers/common/tests/UseApiRoute.test.ts index 445fb70..5282b39 100644 --- a/layers/common/tests/UseApiRoute.test.ts +++ b/layers/common/tests/UseApiRoute.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; -import { createFetch } from 'ofetch'; -import { useApiRoute, apiRouteEmitOptions, apiRouteAuthEventBus, apiRouteFetchError } from '../composables/UseApiRoute'; +import { createFetch, FetchError } from 'ofetch'; +import { useEventBus } from '@vueuse/core'; +import { useApiRoute, apiRouteAuthOptions, apiRouteAuthEventBus, apiRouteFetchError } from '../composables/UseApiRoute'; const session = 'wings-session'; const cookie = `session=${session}; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/`; @@ -114,7 +115,7 @@ describe('useApiRoute with real $fetch requests', () => { }); it('should send POST with Auth Event', async () => { - const opts = apiRouteEmitOptions([401, 403, 'set-cookie']); + const opts = apiRouteAuthOptions([401, 403, 'set-cookie']); const { post } = useApiRoute(opts); const eventSpy = vi.fn(); @@ -122,10 +123,7 @@ describe('useApiRoute with real $fetch requests', () => { eventSpy(ev); }); - await expect(post('/test-401.json')).rejects.toMatchObject({ - status: 401, - }); - + await expect(post('/test-401.json')).resolves.toMatchObject({ success: false }); await expect(post('/test-403.json')).rejects.toSatisfy((error: SafeAny) => { const fe = apiRouteFetchError(error); return fe?.statusCode === 403; @@ -136,4 +134,29 @@ describe('useApiRoute with real $fetch requests', () => { expect(eventSpy).toHaveBeenNthCalledWith(2, { status: 403 }); expect(eventSpy).toHaveBeenNthCalledWith(3, { status: 200, session: session, headers: { 'set-cookie': cookie } }); }); + + it('should send POST with Auth Event replace error or result', async () => { + const authEventBus = useEventBus('test-useapi-event-bus'); + + const opts = apiRouteAuthOptions([401, 403], (evt) => { + authEventBus.emit(evt); + if (evt.status === 403) return new FetchError('error-403'); + }); + const { post } = useApiRoute(opts); + const eventSpy = vi.fn(); + + authEventBus.on((ev) => { + eventSpy(ev); + }); + + await expect(post('/test-401.json')).rejects.toMatchObject({ + status: 401, + }); + await expect(post('/test-403.json')).rejects.toSatisfy((err: SafeAny) => { + return err.cause.message === 'error-403'; + }); + + expect(eventSpy).toHaveBeenNthCalledWith(1, { status: 401 }); + expect(eventSpy).toHaveBeenNthCalledWith(2, { status: 403 }); + }); }); diff --git a/layers/common/utils/typed-fetcher.ts b/layers/common/utils/typed-fetcher.ts index d385319..bd60f3f 100644 --- a/layers/common/utils/typed-fetcher.ts +++ b/layers/common/utils/typed-fetcher.ts @@ -62,7 +62,7 @@ export async function fetchTypedDataAsync( * @param fetching - function/data of DataResult * @param options - options to handle loading, failure, error */ -export function fetchTypedResult( +export function fetchTypedResult>( fetching: T | (() => T), options: TypedFetchOptions = { }, ): T | null { diff --git a/layers/mobile/utils/ionic-fetcher.ts b/layers/mobile/utils/ionic-fetcher.ts index cbdfec1..ac048bb 100644 --- a/layers/mobile/utils/ionic-fetcher.ts +++ b/layers/mobile/utils/ionic-fetcher.ts @@ -19,20 +19,25 @@ const _alerter: AlertHandler = (result, error) => { }; } else { + let header = 'Network Request Error'; let message = error.message || 'Network or Server Error'; const fe = apiRouteFetchError(error); if (fe != null) { // TODO i18n message if (fe.statusCode === 401) { + if (result?.success === false) return false; // handled by emit + + header = 'Unauthorized'; message = 'Please login to continue.'; } else if (fe.statusCode === 403) { + header = 'Forbidden'; message = 'No permission to access'; } } return { - header: 'Network Request Error', + header, message, buttons: ['Close'], };