Correctly handle errors from getPublishedContentByUrl (#142)

* Correctly handle errors from getPublishedContentByUrl

* Format

* Improve error message
This commit is contained in:
Samy Pessé
2024-02-09 19:24:36 +01:00
committed by GitHub
parent dfb8d4e8d4
commit 34e339b5e4
4 changed files with 123 additions and 57 deletions
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -18,7 +18,7 @@
],
"dependencies": {
"@geist-ui/icons": "^1.0.2",
"@gitbook/api": "^0.33.0",
"@gitbook/api": "^0.34.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
"@sentry/nextjs": "^7.94.1",
+59 -33
View File
@@ -56,10 +56,17 @@ export function withAPI<T>(client: GitBookAPI, fn: () => Promise<T>): Promise<T>
return apiSyncStorage.run(client, fn);
}
export type PublishedContentWithCache = PublishedContentLookup & {
cacheMaxAge?: number;
cacheTags?: string[];
};
export type PublishedContentWithCache =
| (PublishedContentLookup & {
cacheMaxAge?: number;
cacheTags?: string[];
})
| {
error: {
code: number;
message: string;
};
};
/**
* Get a user by its ID.
@@ -102,38 +109,57 @@ export const getPublishedContentByUrl = cache(
// We call it as this logic is wrapped in an asynchronous cache that is not tied to the signal.
signal?.throwIfAborted();
const response = await api().request<PublishedContentLookup>({
method: 'GET',
path: '/urls/published',
query: {
url,
visitorAuthToken,
},
secure: false,
format: 'json',
signal: signal,
...noCacheFetchOptions,
});
try {
const response = await api().request<PublishedContentLookup>({
method: 'GET',
path: '/urls/published',
query: {
url,
visitorAuthToken,
},
secure: false,
format: 'json',
signal: signal,
...noCacheFetchOptions,
});
const parsed = parseCacheResponse(response);
const parsed = parseCacheResponse(response);
const tags = [
...parsed.tags,
...('space' in response.data
? [getAPICacheTag({ tag: 'space', space: response.data.space })]
: []),
];
const tags = [
...parsed.tags,
...('space' in response.data
? [getAPICacheTag({ tag: 'space', space: response.data.space })]
: []),
];
const data: PublishedContentWithCache = {
...response.data,
cacheMaxAge: parsed.ttl,
cacheTags: tags,
};
return {
tags,
ttl: parsed.ttl,
data,
};
const data: PublishedContentWithCache = {
...response.data,
cacheMaxAge: parsed.ttl,
cacheTags: tags,
};
return {
tags,
ttl: parsed.ttl,
data,
};
} catch (error) {
const httpError = error as GitBookAPIError;
if (httpError.code < 500) {
return {
data: {
error: {
code: httpError.code,
message: httpError.errorMessage || httpError.message,
},
} as PublishedContentWithCache,
// Cache errors for max 10 minutes in case the user is making changes to its content configuration
ttl: 60 * 10,
tags: [],
};
}
throw error;
}
},
{
// Do not pass the options for the cache key
+63 -23
View File
@@ -99,9 +99,9 @@ export async function middleware(request: NextRequest) {
}),
() => lookupSpaceForURL(mode, inputURL, visitorAuthToken),
);
if (!resolved) {
return new NextResponse(`Content not found`, {
status: 404,
if ('error' in resolved) {
return new NextResponse(resolved.error.message, {
status: resolved.error.code,
headers: {
'x-gitbook-version': buildVersion(),
},
@@ -237,7 +237,7 @@ async function lookupSpaceForURL(
mode: URLLookupMode,
url: URL,
visitorAuthToken: string | undefined,
): Promise<LookupResult | null> {
): Promise<LookupResult> {
switch (mode) {
case 'single': {
return await lookupSpaceInSingleMode(url);
@@ -260,7 +260,7 @@ async function lookupSpaceForURL(
* GITBOOK_MODE=single
* When serving a single space, configured using GITBOOK_SPACE_ID and GITBOOK_TOKEN.
*/
async function lookupSpaceInSingleMode(url: URL): Promise<LookupResult | null> {
async function lookupSpaceInSingleMode(url: URL): Promise<LookupResult> {
const spaceId = process.env.GITBOOK_SPACE_ID;
if (!spaceId) {
throw new Error(
@@ -290,7 +290,7 @@ async function lookupSpaceInSingleMode(url: URL): Promise<LookupResult | null> {
async function lookupSpaceInMultiMode(
url: URL,
visitorAuthToken: string | undefined,
): Promise<LookupResult | null> {
): Promise<LookupResult> {
return lookupSpaceByAPI(url, visitorAuthToken);
}
@@ -298,24 +298,36 @@ async function lookupSpaceInMultiMode(
* GITBOOK_MODE=multi-id
* When serving multi spaces with the ID passed in the path.
*/
async function lookupSpaceInMultiIdMode(url: URL): Promise<LookupResult | null> {
async function lookupSpaceInMultiIdMode(url: URL): Promise<LookupResult> {
// Extract the iD from the path
const pathSegments = url.pathname.slice(1).split('/');
if (pathSegments[0] !== '~space') {
throw new Error(`Invalid path, expected ~space`);
return null;
return {
error: {
code: 400,
message: `Missing space ID in the path`,
},
};
}
const spaceId = pathSegments[1];
if (!spaceId) {
throw new Error(`Missing space ID in the path`);
return null;
return {
error: {
code: 400,
message: `Missing space ID in the path`,
},
};
}
// Get the auth token from the URL query
const apiToken = url.searchParams.get('token');
if (!apiToken) {
throw new Error(`Missing token query parameter`);
return null;
return {
error: {
code: 400,
message: `Missing token query parameter`,
},
};
}
const apiEndpoint = url.searchParams.get('api') ?? api().endpoint;
@@ -346,28 +358,45 @@ async function lookupSpaceInMultiIdMode(url: URL): Promise<LookupResult | null>
async function lookupSpaceInMultiPathMode(
url: URL,
visitorAuthToken: string | undefined,
): Promise<LookupResult | null> {
): Promise<LookupResult> {
// Skip useless requests
if (
url.pathname === '/favicon.ico' ||
url.pathname === '/robots.txt' ||
url.pathname === '/sitemap.xml' ||
// Match something that starts with a domain like
!url.pathname.match(/^.+\..+/)
url.pathname === '/sitemap.xml'
) {
return null;
return {
error: {
code: 404,
message: `favicon.ico, robots.txt, sitemap.xml should be accessed under a content`,
},
};
}
// Only match something that starts with a domain like
if (!url.pathname.match(/^.+\..+/)) {
return {
error: {
code: 404,
message: `Invalid URL in the path, should start with a domain`,
},
};
}
const targetStr = `https://${url.pathname}`;
if (!URL.canParse(targetStr)) {
throw new Error(`Invalid URL in the path`);
return {
error: {
code: 404,
message: `Invalid URL in the path`,
},
};
}
const target = new URL(targetStr);
const lookup = await lookupSpaceByAPI(target, visitorAuthToken);
if (!lookup) {
return null;
if ('error' in lookup) {
return lookup;
}
if ('redirect' in lookup) {
@@ -400,7 +429,7 @@ async function lookupSpaceInMultiPathMode(
async function lookupSpaceByAPI(
url: URL,
visitorAuthToken: string | undefined,
): Promise<LookupResult | null> {
): Promise<LookupResult> {
const lookupAlternatives = getURLLookupAlternatives(stripURLSearch(url));
console.log(
@@ -418,6 +447,10 @@ async function lookupSpaceByAPI(
signal: abort.signal,
});
if ('error' in data) {
return data;
}
if ('redirect' in data) {
if (alternative.url === url.toString()) {
return data;
@@ -449,7 +482,14 @@ async function lookupSpaceByAPI(
);
console.log(`lookup took ${Date.now() - startTime}ms`);
return matches.find((match) => match !== null) ?? null;
return (
matches.find((match) => match !== null) ?? {
error: {
code: 404,
message: `No content found`,
},
}
);
}
function joinPath(...parts: string[]): string {