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.
|
||||
|
||||
#### 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
|
||||
|
||||
`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(
|
||||
method: HttpVerb,
|
||||
url: URL,
|
||||
body: string,
|
||||
body?: string,
|
||||
headers?: Headers,
|
||||
date?: Date,
|
||||
): Promise<{
|
||||
|
|
@ -302,7 +302,7 @@ export class SignatureConstructor {
|
|||
return { request, signedString };
|
||||
}
|
||||
|
||||
if (!(url && body && headers)) {
|
||||
if (!(url && headers)) {
|
||||
throw new TypeError(
|
||||
"Missing or empty required parameters: url, body or headers",
|
||||
);
|
||||
|
|
@ -312,7 +312,7 @@ export class SignatureConstructor {
|
|||
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(body),
|
||||
new TextEncoder().encode(body ?? ""),
|
||||
);
|
||||
|
||||
const signedString =
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"version": "0.0.0",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./types": "./schemas.ts"
|
||||
"./types": "./schemas.ts",
|
||||
"./requester": "./requester.ts"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@
|
|||
"import": "./index.ts",
|
||||
"default": "./index.ts"
|
||||
},
|
||||
"./requester": {
|
||||
"import": "./requester.ts",
|
||||
"default": "./requester.ts"
|
||||
},
|
||||
"./types": {
|
||||
"import": "./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