feat(api): add v2 phase one skeleton

This commit is contained in:
yoyuzh
2026-04-08 14:28:01 +08:00
parent 3afebbb338
commit 9d5fdd9ea3
20 changed files with 1585 additions and 2 deletions

View File

@@ -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);

View File

@@ -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');
}