mirror of
https://github.com/versia-pub/api.git
synced 2025-12-06 08:28:19 +01:00
feat(federation): ✨ Add a federation requester client
This commit is contained in:
parent
7e3db6fc2b
commit
bbcc362bc1
|
|
@ -52,6 +52,31 @@ const validNote = await validator.Note(validNoteObject);
|
||||||
|
|
||||||
Your editor's IntelliSense should provide you with every method and property available, which all match the [**Lysand**](https://lysand.org) specification names.
|
Your editor's IntelliSense should provide you with every method and property available, which all match the [**Lysand**](https://lysand.org) specification names.
|
||||||
|
|
||||||
|
#### Requester
|
||||||
|
|
||||||
|
A `FederationRequester` class is provided to make requests to a remote server. It sets the correct headers and has multiple methods to make requesters easier.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { FederationRequester } from "@lysand-org/federation/requester";
|
||||||
|
import { SignatureConstructor } from "@lysand-org/federation/cryptography";
|
||||||
|
|
||||||
|
const requester = new FederationRequester(
|
||||||
|
new URL("https://example.com"),
|
||||||
|
new SignatureConstructor(privateKey, keyId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, ok } = await requester.get<User>("/users/1");
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
console.error(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(data);
|
||||||
|
|
||||||
|
// Do a WebFinger request
|
||||||
|
const userProfileUri = await requester.webFinger("banana");
|
||||||
|
```
|
||||||
|
|
||||||
#### Validation Helper
|
#### Validation Helper
|
||||||
|
|
||||||
`RequestParserHandler` is a class to parse the body of a request and call the appropriate callback. It is a helper for the `EntityValidator` class.
|
`RequestParserHandler` is a class to parse the body of a request and call the appropriate callback. It is a helper for the `EntityValidator` class.
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,7 @@ export class SignatureConstructor {
|
||||||
async sign(
|
async sign(
|
||||||
method: HttpVerb,
|
method: HttpVerb,
|
||||||
url: URL,
|
url: URL,
|
||||||
body: string,
|
body?: string,
|
||||||
headers?: Headers,
|
headers?: Headers,
|
||||||
date?: Date,
|
date?: Date,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
|
|
@ -302,7 +302,7 @@ export class SignatureConstructor {
|
||||||
return { request, signedString };
|
return { request, signedString };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(url && body && headers)) {
|
if (!(url && headers)) {
|
||||||
throw new TypeError(
|
throw new TypeError(
|
||||||
"Missing or empty required parameters: url, body or headers",
|
"Missing or empty required parameters: url, body or headers",
|
||||||
);
|
);
|
||||||
|
|
@ -312,7 +312,7 @@ export class SignatureConstructor {
|
||||||
|
|
||||||
const digest = await crypto.subtle.digest(
|
const digest = await crypto.subtle.digest(
|
||||||
"SHA-256",
|
"SHA-256",
|
||||||
new TextEncoder().encode(body),
|
new TextEncoder().encode(body ?? ""),
|
||||||
);
|
);
|
||||||
|
|
||||||
const signedString =
|
const signedString =
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
"./types": "./schemas.ts"
|
"./types": "./schemas.ts",
|
||||||
|
"./requester": "./requester.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@
|
||||||
"import": "./index.ts",
|
"import": "./index.ts",
|
||||||
"default": "./index.ts"
|
"default": "./index.ts"
|
||||||
},
|
},
|
||||||
|
"./requester": {
|
||||||
|
"import": "./requester.ts",
|
||||||
|
"default": "./requester.ts"
|
||||||
|
},
|
||||||
"./types": {
|
"./types": {
|
||||||
"import": "./schemas.ts",
|
"import": "./schemas.ts",
|
||||||
"default": "./schemas.ts"
|
"default": "./schemas.ts"
|
||||||
|
|
|
||||||
7
federation/requester.ts
Normal file
7
federation/requester.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import {
|
||||||
|
FederationRequester,
|
||||||
|
type Output,
|
||||||
|
ResponseError,
|
||||||
|
} from "./requester/index";
|
||||||
|
|
||||||
|
export { type Output, ResponseError, FederationRequester };
|
||||||
3
federation/requester/constants.ts
Normal file
3
federation/requester/constants.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import pkg from "../package.json" with { type: "json" };
|
||||||
|
|
||||||
|
export const DEFAULT_UA = `LysandFederation/${pkg.version} (+${pkg.homepage})`;
|
||||||
202
federation/requester/index.ts
Normal file
202
federation/requester/index.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
import type { SignatureConstructor } from "../cryptography";
|
||||||
|
import type { User } from "../schemas";
|
||||||
|
import { WebFingerSchema } from "../schemas/webfinger";
|
||||||
|
import { DEFAULT_UA } from "./constants";
|
||||||
|
|
||||||
|
type HttpVerb = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output of a request. Contains the data and headers.
|
||||||
|
* @template ReturnType The type of the data returned by the request.
|
||||||
|
*/
|
||||||
|
export interface Output<ReturnType> {
|
||||||
|
data: ReturnType;
|
||||||
|
ok: boolean;
|
||||||
|
raw: Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around Error, useful for detecting if an error
|
||||||
|
* is due to a failed request.
|
||||||
|
*
|
||||||
|
* Throws if the request returns invalid or unexpected data.
|
||||||
|
*/
|
||||||
|
export class ResponseError<
|
||||||
|
ReturnType = {
|
||||||
|
error?: string;
|
||||||
|
},
|
||||||
|
> extends Error {
|
||||||
|
constructor(
|
||||||
|
public response: Output<ReturnType>,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ResponseError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class to handle requests to a remote server.
|
||||||
|
* @param serverUrl The URL of the server to send requests to.
|
||||||
|
* @param signatureConstructor The constructor to sign requests with.
|
||||||
|
* @param globalCatch A function to call when a request fails.
|
||||||
|
* @example
|
||||||
|
* const requester = new FederationRequester(
|
||||||
|
* new URL("https://example.com"),
|
||||||
|
* new SignatureConstructor(privateKey, keyId),
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* const { data, ok } = await requester.get<User>("/users/1");
|
||||||
|
*
|
||||||
|
* if (!ok) {
|
||||||
|
* console.error(data);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* console.log(data);
|
||||||
|
*/
|
||||||
|
export class FederationRequester {
|
||||||
|
constructor(
|
||||||
|
private serverUrl: URL,
|
||||||
|
private signatureConstructor: SignatureConstructor,
|
||||||
|
public globalCatch: (error: ResponseError) => void = () => {
|
||||||
|
// Do nothing by default
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get url(): URL {
|
||||||
|
return this.serverUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's profile link from their username.
|
||||||
|
* @param username The username to get the profile link for.
|
||||||
|
* @returns The user's profile link.
|
||||||
|
* @throws If the request fails or the response is invalid.
|
||||||
|
* @example
|
||||||
|
* const profileLink = await requester.webFinger("example");
|
||||||
|
*
|
||||||
|
* console.log(profileLink);
|
||||||
|
* // => "https://example.com/users/1"
|
||||||
|
*/
|
||||||
|
public async webFinger(username: string): Promise<string> {
|
||||||
|
const result = await this.get<User>(
|
||||||
|
`/.well-known/webfinger?${new URLSearchParams({
|
||||||
|
resource: `acct:${username}@${this.serverUrl.hostname}`,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate the response
|
||||||
|
const { error, success, data } = await WebFingerSchema.safeParseAsync(
|
||||||
|
result.data,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw fromZodError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first link with a rel of "self"
|
||||||
|
const selfLink = data.links?.find((link) => link.rel === "self");
|
||||||
|
|
||||||
|
if (!selfLink) {
|
||||||
|
throw new Error(
|
||||||
|
"No link with rel=self found in WebFinger response",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selfLink.href) {
|
||||||
|
throw new Error(
|
||||||
|
"Link with rel=self has no href in WebFinger response",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return user's profile link
|
||||||
|
return selfLink.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<ReturnType>(
|
||||||
|
request: Request,
|
||||||
|
): Promise<Output<ReturnType>> {
|
||||||
|
const result = await fetch(request);
|
||||||
|
const isJson = result.headers.get("Content-Type")?.includes("json");
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
const error = isJson ? await result.json() : await result.text();
|
||||||
|
throw new ResponseError(
|
||||||
|
{
|
||||||
|
data: error,
|
||||||
|
ok: false,
|
||||||
|
raw: result,
|
||||||
|
},
|
||||||
|
`Request failed (${result.status}): ${
|
||||||
|
error.error || error.message || result.statusText
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: isJson ? await result.json() : (await result.text()) || null,
|
||||||
|
ok: true,
|
||||||
|
raw: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async constructRequest(
|
||||||
|
path: string,
|
||||||
|
method: HttpVerb,
|
||||||
|
body?: object | FormData,
|
||||||
|
extra?: RequestInit,
|
||||||
|
): Promise<Request> {
|
||||||
|
const headers = new Headers({
|
||||||
|
"User-Agent": DEFAULT_UA,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
headers.set("Content-Type", "application/json; charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(extra?.headers || {})) {
|
||||||
|
headers.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set("Accept", "application/json");
|
||||||
|
|
||||||
|
const request = new Request(new URL(path, this.serverUrl).toString(), {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body
|
||||||
|
? body instanceof FormData
|
||||||
|
? body
|
||||||
|
: JSON.stringify(body)
|
||||||
|
: undefined,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (await this.signatureConstructor.sign(request)).request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get<ReturnType>(
|
||||||
|
path: string,
|
||||||
|
extra?: RequestInit,
|
||||||
|
): Promise<Output<ReturnType>> {
|
||||||
|
return this.request<ReturnType>(
|
||||||
|
await this.constructRequest(path, "GET", undefined, extra),
|
||||||
|
).catch((e) => {
|
||||||
|
this.globalCatch(e);
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async post<ReturnType>(
|
||||||
|
path: string,
|
||||||
|
body: object,
|
||||||
|
extra?: RequestInit,
|
||||||
|
): Promise<Output<ReturnType>> {
|
||||||
|
return this.request<ReturnType>(
|
||||||
|
await this.constructRequest(path, "POST", body, extra),
|
||||||
|
).catch((e) => {
|
||||||
|
this.globalCatch(e);
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
20
federation/schemas/webfinger.ts
Normal file
20
federation/schemas/webfinger.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const WebFingerSchema = z.object({
|
||||||
|
subject: z.string().url(),
|
||||||
|
aliases: z.array(z.string().url()).optional(),
|
||||||
|
properties: z.record(z.string().url(), z.string().or(z.null())).optional(),
|
||||||
|
links: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
rel: z.string(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
href: z.string().url().optional(),
|
||||||
|
titles: z.record(z.string(), z.string()).optional(),
|
||||||
|
properties: z
|
||||||
|
.record(z.string().url(), z.string().or(z.null()))
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue