Skip to content

Commit

Permalink
feat: added support for custom fetch interceptors
Browse files Browse the repository at this point in the history
  • Loading branch information
manchenkoff committed May 29, 2024
1 parent 0a75a4c commit 619388a
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 149 deletions.
184 changes: 35 additions & 149 deletions src/runtime/httpFactory.ts
Original file line number Diff line number Diff line change
@@ -1,171 +1,57 @@
import type { $Fetch, FetchOptions } from 'ofetch';
import { appendResponseHeader, splitCookiesString } from 'h3';
import {
useCookie,
useRequestEvent,
useRequestHeaders,
useRequestURL,
navigateTo,
useNuxtApp,
} from '#app';
import { useSanctumUser } from './composables/useSanctumUser';
import type { $Fetch, FetchOptions, FetchContext } from 'ofetch';
import { useNuxtApp, useAppConfig } from '#app';
import { useSanctumConfig } from './composables/useSanctumConfig';
import { type ConsolaInstance } from 'consola';

type Headers = HeadersInit | undefined;

const SECURE_METHODS = new Set(['post', 'delete', 'put', 'patch']);
const COOKIE_OPTIONS: { readonly: true } = { readonly: true };
import type { ConsolaInstance } from 'consola';
import handleRequestCookies from './interceptors/cookie/request';
import handleResponseHeaders from './interceptors/cookie/response';
import handleResponseError from './interceptors/common/error';
import type { SanctumAppConfig } from './types';

export function createHttpClient(logger: ConsolaInstance): $Fetch {
const options = useSanctumConfig();
const user = useSanctumUser();
const config = useSanctumConfig();
const appConfig = useAppConfig().sanctum as SanctumAppConfig | undefined;
const nuxtApp = useNuxtApp();
const event = useRequestEvent(nuxtApp);

/**
* Request a new CSRF cookie from the API
* @returns {Promise<void>}
*/
async function initCsrfCookie(): Promise<void> {
await $fetch(options.endpoints.csrf, {
baseURL: options.baseUrl,
credentials: 'include',
});

logger.debug('CSRF cookie has been initialized');
}

/**
* Add CSRF token to the headers collection to pass from the client to the API
* @param headers Headers collection to extend
* @returns {Promise<HeadersInit>}
*/
async function useCsrfHeader(headers: Headers): Promise<HeadersInit> {
let csrfToken = useCookie(options.csrf.cookie, COOKIE_OPTIONS);

if (!csrfToken.value) {
await initCsrfCookie();

csrfToken = useCookie(options.csrf.cookie, COOKIE_OPTIONS);
}
const requestInterceptors = [handleRequestCookies];
const responseInterceptors = [handleResponseHeaders];
const errorInterceptors = [handleResponseError];

if (!csrfToken.value) {
logger.warn(
`${options.csrf.cookie} cookie is missing, unable to set ${options.csrf.header} header`
);

return headers as HeadersInit;
}

logger.debug(`Added ${options.csrf.header} header to pass to the API`);

return {
...headers,
...(csrfToken.value && { [options.csrf.header]: csrfToken.value }),
};
if (appConfig?.interceptors?.onRequest) {
requestInterceptors.push(appConfig.interceptors.onRequest);
}

/**
* Pass all cookies, headers and referrer from the client to the API
* @param headers Headers collection to extend
* @returns {HeadersInit}
*/
function buildServerHeaders(headers: Headers): HeadersInit {
const clientCookies = useRequestHeaders(['cookie']);
const origin = options.origin ?? useRequestURL().origin;

return {
...headers,
Referer: origin,
Origin: origin,
...(clientCookies.cookie && clientCookies),
};
if (appConfig?.interceptors?.onResponse) {
responseInterceptors.push(appConfig.interceptors.onResponse);
}

const httpOptions: FetchOptions = {
baseURL: options.baseUrl,
baseURL: config.baseUrl,
credentials: 'include',
redirect: 'manual',
retry: options.client.retry,

async onRequest({ options }): Promise<void> {
const method = options.method?.toLowerCase() ?? 'get';

options.headers = {
Accept: 'application/json',
...options.headers,
};

// https://laravel.com/docs/10.x/routing#form-method-spoofing
if (method === 'put' && options.body instanceof FormData) {
options.method = 'POST';
options.body.append('_method', 'PUT');
}
retry: config.client.retry,

if (import.meta.server) {
options.headers = buildServerHeaders(options.headers);
}

if (SECURE_METHODS.has(method)) {
options.headers = await useCsrfHeader(options.headers);
}
},

async onResponse({ request, response }): Promise<void> {
// pass all cookies from the API to the client on SSR response
if (import.meta.server) {
const serverCookieName = 'set-cookie';
const cookieHeader = response.headers.get(serverCookieName);

if (cookieHeader === null || event === undefined) {
logger.debug(
`No cookies to pass to the client [${request}]`
);

return;
async onRequest(context: FetchContext): Promise<void> {
await nuxtApp.runWithContext(() => {
for (const interceptor of requestInterceptors) {
interceptor(nuxtApp, context, logger);
}
});
},

const cookies = splitCookiesString(cookieHeader);
const cookieNameList = [];

for (const cookie of cookies) {
appendResponseHeader(event, serverCookieName, cookie);

const cookieName = cookie.split('=')[0];
cookieNameList.push(cookieName);
async onResponse(context: FetchContext): Promise<void> {
await nuxtApp.runWithContext(() => {
for (const interceptor of responseInterceptors) {
interceptor(nuxtApp, context, logger);
}

logger.debug(
`Append API cookies from SSR to CSR response [${cookieNameList.join(', ')}]`
);
}

// follow redirects on client
if (response.redirected) {
await nuxtApp.runWithContext(() => navigateTo(response.url));
}
});
},

async onResponseError({ request, response }): Promise<void> {
if (response.status === 419) {
logger.warn(
'CSRF token mismatch, check your API configuration'
);

return;
}

if (
response.status === 401 &&
request.toString().endsWith(options.endpoints.user) &&
user.value !== null
) {
logger.warn(
'User session is not set in API or expired, resetting identity'
);
user.value = null;
}
async onResponseError(context: FetchContext): Promise<void> {
await nuxtApp.runWithContext(() => {
for (const interceptor of errorInterceptors) {
interceptor(nuxtApp, context, logger);
}
});
},
};

Expand Down
37 changes: 37 additions & 0 deletions src/runtime/interceptors/common/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { NuxtApp } from '#app';
import type { FetchContext } from 'ofetch';
import type { ConsolaInstance } from 'consola';
import { useSanctumUser } from '../../composables/useSanctumUser';
import { useSanctumConfig } from '../../composables/useSanctumConfig';

export default async function handleResponseError(
app: NuxtApp,
ctx: FetchContext,
logger: ConsolaInstance
) {
if (ctx.response === undefined) {
logger.debug('No response to process');

return;
}

const user = useSanctumUser();
const config = useSanctumConfig();

if (ctx.response.status === 419) {
logger.warn('CSRF token mismatch, check your API configuration');

return;
}

if (
ctx.response.status === 401 &&
ctx.request.toString().endsWith(config.endpoints.user) &&
user.value !== null
) {
logger.warn(
'User session is not set in API or expired, resetting identity'
);
user.value = null;
}
}
20 changes: 20 additions & 0 deletions src/runtime/interceptors/common/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { type NuxtApp } from '#app';
import type { FetchContext } from 'ofetch';

export default async function handleRequestHeaders(
app: NuxtApp,
ctx: FetchContext
) {
const method = ctx.options.method?.toLowerCase() ?? 'get';

ctx.options.headers = {
Accept: 'application/json',
...ctx.options.headers,
};

// https://laravel.com/docs/10.x/routing#form-method-spoofing
if (method === 'put' && ctx.options.body instanceof FormData) {
ctx.options.method = 'POST';
ctx.options.body.append('_method', 'PUT');
}
}
113 changes: 113 additions & 0 deletions src/runtime/interceptors/cookie/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import {
useCookie,
useRequestHeaders,
useRequestURL,
type NuxtApp,
} from '#app';
import type { FetchContext } from 'ofetch';
import type { ConsolaInstance } from 'consola';
import { useSanctumConfig } from '../../composables/useSanctumConfig';
import type { SanctumModuleOptions } from '../../types';

type Headers = HeadersInit | undefined;

const SECURE_METHODS = new Set(['post', 'delete', 'put', 'patch']);
const COOKIE_OPTIONS: { readonly: true } = { readonly: true };

/**
* Pass all cookies, headers and referrer from the client to the API
* @param headers Headers collection to extend
* @param config Module configuration
* @returns {HeadersInit}
*/
function buildServerHeaders(
headers: Headers,
config: SanctumModuleOptions
): HeadersInit {
const clientCookies = useRequestHeaders(['cookie']);
const origin = config.origin ?? useRequestURL().origin;

return {
...headers,
Referer: origin,
Origin: origin,
...(clientCookies.cookie && clientCookies),
};
}

/**
* Request a new CSRF cookie from the API
* @param config Module configuration
* @param logger Logger instance
* @returns {Promise<void>}
*/
async function initCsrfCookie(
config: SanctumModuleOptions,
logger: ConsolaInstance
): Promise<void> {
await $fetch(config.endpoints.csrf, {
baseURL: config.baseUrl,
credentials: 'include',
});

logger.debug('CSRF cookie has been initialized');
}

/**
* Add CSRF token to the headers collection to pass from the client to the API
* @param headers Headers collection to extend
* @param config Module configuration
* @param logger Logger instance
* @returns {Promise<HeadersInit>}
*/
async function useCsrfHeader(
headers: Headers,
config: SanctumModuleOptions,
logger: ConsolaInstance
): Promise<HeadersInit> {
let csrfToken = useCookie(config.csrf.cookie, COOKIE_OPTIONS);

if (!csrfToken.value) {
await initCsrfCookie(config, logger);

csrfToken = useCookie(config.csrf.cookie, COOKIE_OPTIONS);
}

if (!csrfToken.value) {
logger.warn(
`${config.csrf.cookie} cookie is missing, unable to set ${config.csrf.header} header`
);

return headers as HeadersInit;
}

logger.debug(`Added ${config.csrf.header} header to pass to the API`);

return {
...headers,
...(csrfToken.value && {
[config.csrf.header]: csrfToken.value,
}),
};
}

export default async function handleRequestCookies(
app: NuxtApp,
ctx: FetchContext,
logger: ConsolaInstance
) {
const config = useSanctumConfig();
const method = ctx.options.method?.toLowerCase() ?? 'get';

if (import.meta.server) {
ctx.options.headers = buildServerHeaders(ctx.options.headers, config);
}

if (SECURE_METHODS.has(method)) {
ctx.options.headers = await useCsrfHeader(
ctx.options.headers,
config,
logger
);
}
}
Loading

0 comments on commit 619388a

Please sign in to comment.