Files
channels-seerr/server/api/externalapi.ts
Hermanus Engelbrecht f5b3b06dfe feat(new auth api): add EmbyConnect authentication API
Created an Emby Connect API with the required methods to orchestrate a set of calls towards
EmbyConnect to build a JellyseerrLoginResponse object. Added an EmbyConnect authentication call to
the Jellyfin login() method as the last authentication attempt, but only if the username is an email
address. Updated the post() method on the ExternalApi class to handle request bodies that need to be
x-www-form-urlencoded.

#749
2024-08-24 13:36:16 +02:00

347 lines
8.5 KiB
TypeScript

import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit from '@server/utils/rateLimit';
import type NodeCache from 'node-cache';
import querystring from 'querystring';
// 5 minute default TTL (in seconds)
const DEFAULT_TTL = 300;
// 10 seconds default rolling buffer (in ms)
const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
rateLimit?: RateLimitOptions;
}
class ExternalAPI {
protected fetch: typeof fetch;
protected params: Record<string, string>;
protected defaultHeaders: { [key: string]: string };
private baseUrl: string;
private cache?: NodeCache;
constructor(
baseUrl: string,
params: Record<string, string> = {},
options: ExternalAPIOptions = {}
) {
if (options.rateLimit) {
this.fetch = rateLimit(fetch, options.rateLimit);
} else {
this.fetch = fetch;
}
this.baseUrl = baseUrl;
this.params = params;
this.defaultHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
};
this.cache = options.nodeCache;
}
protected async get<T>(
endpoint: string,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
if (this.cache) {
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}
return data;
}
protected async post<T>(
endpoint: string,
data?: Record<string, unknown>,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: { ...this.params, ...params },
data,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const url = this.formatUrl(endpoint, params);
const headers = new Headers({
...this.defaultHeaders,
...(config?.headers || {}),
});
const isFormUrlEncoded = headers
.get('Content-Type')
?.includes('application/x-www-form-urlencoded');
const body = data
? isFormUrlEncoded
? querystring.stringify(data as Record<string, string>)
: JSON.stringify(data)
: undefined;
const response = await this.fetch(url, {
method: 'POST',
...config,
headers,
body: body,
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const resData = await this.getDataFromResponse(response);
if (this.cache) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}
return resData;
}
protected async put<T>(
endpoint: string,
data: Record<string, unknown>,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: { ...this.params, ...params },
data,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'PUT',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
body: JSON.stringify(data),
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const resData = await this.getDataFromResponse(response);
if (this.cache) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}
return resData;
}
protected async delete<T>(
endpoint: string,
params?: Record<string, string>,
config?: RequestInit
): Promise<T> {
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'DELETE',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
return data;
}
protected async getRolling<T>(
endpoint: string,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit,
overwriteBaseUrl?: string
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
const keyTtl = this.cache?.getTtl(cacheKey) ?? 0;
// If the item has passed our rolling check, fetch again in background
if (
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
Date.now() - DEFAULT_ROLLING_BUFFER
) {
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
}).then(async (response) => {
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${
text ? ': ' + text : ''
}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
});
}
return cachedItem;
}
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
const response = await this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
if (this.cache) {
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}
return data;
}
private formatUrl(
endpoint: string,
params?: Record<string, string>,
overwriteBaseUrl?: string
): string {
const baseUrl = overwriteBaseUrl || this.baseUrl;
const href =
baseUrl +
(baseUrl.endsWith('/') ? '' : '/') +
(endpoint.startsWith('/') ? endpoint.slice(1) : endpoint);
const searchParams = new URLSearchParams({
...this.params,
...params,
});
return (
href +
(searchParams.toString().length
? '?' + searchParams.toString()
: searchParams.toString())
);
}
private serializeCacheKey(
endpoint: string,
params?: Record<string, unknown>
) {
if (!params) {
return `${this.baseUrl}${endpoint}`;
}
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
}
private async getDataFromResponse(response: Response) {
const contentType = response.headers.get('Content-Type');
if (contentType?.includes('application/json')) {
return await response.json();
} else if (
contentType?.includes('application/xml') ||
contentType?.includes('text/html') ||
contentType?.includes('text/plain')
) {
return await response.text();
} else {
try {
return await response.json();
} catch {
try {
return await response.blob();
} catch {
return null;
}
}
}
}
}
export default ExternalAPI;