feat(api): add v2 phase one skeleton
This commit is contained in:
@@ -1,7 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, beforeEach, test } from 'node:test';
|
||||
|
||||
import { apiBinaryUploadRequest, apiRequest, apiUploadRequest, shouldRetryRequest, toNetworkApiError } from './api';
|
||||
import {
|
||||
YOYUZH_CLIENT_ID_HEADER,
|
||||
apiBinaryUploadRequest,
|
||||
apiRequest,
|
||||
apiUploadRequest,
|
||||
apiV2Request,
|
||||
resolveYoyuzhClientId,
|
||||
shouldRetryRequest,
|
||||
toNetworkApiError,
|
||||
} from './api';
|
||||
import { clearStoredSession, readStoredSession, saveStoredSession } from './session';
|
||||
|
||||
class MemoryStorage implements Storage {
|
||||
@@ -183,9 +192,51 @@ test('apiRequest attaches bearer token and unwraps response payload', async () =
|
||||
assert.ok(request instanceof Request);
|
||||
assert.equal(request.headers.get('Authorization'), 'Bearer token-123');
|
||||
assert.equal(request.headers.get('X-Yoyuzh-Client'), 'desktop');
|
||||
assert.equal(request.headers.get(YOYUZH_CLIENT_ID_HEADER), resolveYoyuzhClientId());
|
||||
assert.equal(request.url, 'http://localhost/api/files/recent');
|
||||
});
|
||||
|
||||
test('apiV2Request prefixes v2 paths and attaches a stable client id header', async () => {
|
||||
let request: Request | URL | string | undefined;
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
request =
|
||||
input instanceof Request
|
||||
? input
|
||||
: new Request(new URL(String(input), 'http://localhost'), init);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
status: 'ok',
|
||||
apiVersion: 'v2',
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const payload = await apiV2Request<{status: string; apiVersion: string}>('/site/ping');
|
||||
|
||||
assert.deepEqual(payload, {status: 'ok', apiVersion: 'v2'});
|
||||
assert.ok(request instanceof Request);
|
||||
assert.equal(request.url, 'http://localhost/api/v2/site/ping');
|
||||
assert.equal(request.headers.get(YOYUZH_CLIENT_ID_HEADER), resolveYoyuzhClientId());
|
||||
});
|
||||
|
||||
test('resolveYoyuzhClientId reuses the same generated client id for later requests', () => {
|
||||
const firstClientId = resolveYoyuzhClientId();
|
||||
const secondClientId = resolveYoyuzhClientId();
|
||||
|
||||
assert.equal(secondClientId, firstClientId);
|
||||
assert.match(firstClientId, /^yoyuzh-client-[a-zA-Z0-9-]+$/);
|
||||
});
|
||||
|
||||
test('apiRequest uses the production api origin inside the Capacitor localhost shell', async () => {
|
||||
let request: Request | URL | string | undefined;
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
@@ -338,6 +389,7 @@ test('apiUploadRequest attaches auth header and forwards upload progress', async
|
||||
assert.equal(request.url, '/api/files/upload?path=%2F');
|
||||
assert.equal(request.headers.get('authorization'), 'Bearer token-456');
|
||||
assert.equal(request.headers.get('accept'), 'application/json');
|
||||
assert.equal(request.headers.get(YOYUZH_CLIENT_ID_HEADER.toLowerCase()), resolveYoyuzhClientId());
|
||||
assert.equal(request.requestBody, formData);
|
||||
|
||||
request.triggerProgress(128, 512);
|
||||
|
||||
@@ -31,8 +31,12 @@ interface ApiBinaryUploadRequestInit {
|
||||
const AUTH_REFRESH_PATH = '/auth/refresh';
|
||||
const DEFAULT_API_BASE_URL = '/api';
|
||||
const DEFAULT_CAPACITOR_API_ORIGIN = 'https://api.yoyuzh.xyz';
|
||||
const YOYUZH_CLIENT_ID_STORAGE_KEY = 'yoyuzh.clientId';
|
||||
|
||||
export const YOYUZH_CLIENT_ID_HEADER = 'X-Yoyuzh-Client-Id';
|
||||
|
||||
let refreshRequestPromise: Promise<boolean> | null = null;
|
||||
let fallbackClientId: string | null = null;
|
||||
|
||||
export class ApiError extends Error {
|
||||
code?: number;
|
||||
@@ -149,10 +153,43 @@ function normalizePath(path: string) {
|
||||
return path.startsWith('/') ? path : `/${path}`;
|
||||
}
|
||||
|
||||
function resolveV2Path(path: string) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
return normalizedPath.startsWith('/v2/') ? normalizedPath : `/v2${normalizedPath}`;
|
||||
}
|
||||
|
||||
function shouldAttachPortalClientHeader(path: string) {
|
||||
return !/^https?:\/\//.test(path);
|
||||
}
|
||||
|
||||
function shouldAttachYoyuzhClientIdHeader(path: string) {
|
||||
return !/^https?:\/\//.test(path);
|
||||
}
|
||||
|
||||
function createYoyuzhClientId() {
|
||||
const randomId =
|
||||
typeof globalThis.crypto?.randomUUID === 'function'
|
||||
? globalThis.crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
return `yoyuzh-client-${randomId}`;
|
||||
}
|
||||
|
||||
export function resolveYoyuzhClientId() {
|
||||
if (typeof globalThis.localStorage === 'undefined') {
|
||||
fallbackClientId ??= createYoyuzhClientId();
|
||||
return fallbackClientId;
|
||||
}
|
||||
|
||||
const storedClientId = globalThis.localStorage.getItem(YOYUZH_CLIENT_ID_STORAGE_KEY);
|
||||
if (storedClientId) {
|
||||
return storedClientId;
|
||||
}
|
||||
|
||||
const clientId = createYoyuzhClientId();
|
||||
globalThis.localStorage.setItem(YOYUZH_CLIENT_ID_STORAGE_KEY, clientId);
|
||||
return clientId;
|
||||
}
|
||||
|
||||
function shouldAttemptTokenRefresh(path: string) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
return ![
|
||||
@@ -280,6 +317,9 @@ async function performRequest(path: string, init: ApiRequestInit = {}, allowRefr
|
||||
if (shouldAttachPortalClientHeader(path) && !headers.has(PORTAL_CLIENT_HEADER)) {
|
||||
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
|
||||
}
|
||||
if (shouldAttachYoyuzhClientIdHeader(path) && !headers.has(YOYUZH_CLIENT_ID_HEADER)) {
|
||||
headers.set(YOYUZH_CLIENT_ID_HEADER, resolveYoyuzhClientId());
|
||||
}
|
||||
if (requestBody && !(requestBody instanceof FormData) && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
@@ -345,6 +385,10 @@ export async function apiRequest<T>(path: string, init?: ApiRequestInit) {
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
export function apiV2Request<T>(path: string, init?: ApiRequestInit) {
|
||||
return apiRequest<T>(resolveV2Path(path), init);
|
||||
}
|
||||
|
||||
function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, allowRefresh: boolean): Promise<T> {
|
||||
const session = readStoredSession();
|
||||
const headers = new Headers(init.headers);
|
||||
@@ -355,6 +399,9 @@ function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, a
|
||||
if (shouldAttachPortalClientHeader(path) && !headers.has(PORTAL_CLIENT_HEADER)) {
|
||||
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
|
||||
}
|
||||
if (shouldAttachYoyuzhClientIdHeader(path) && !headers.has(YOYUZH_CLIENT_ID_HEADER)) {
|
||||
headers.set(YOYUZH_CLIENT_ID_HEADER, resolveYoyuzhClientId());
|
||||
}
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user