Skip to content

feat(unstable-api): Implement the /unstable-api submodule partially #226

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Mar 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export * as pages from "./api/pages.ts";
export * as projects from "./api/projects.ts";
export * as users from "./api/users.ts";
export type { HTTPError, TypedError } from "./error.ts";
export type { BaseOptions, ExtendedOptions, OAuthOptions } from "./util.ts";

export {
get as listPages,
list as listPagesStream,
type ListPagesOption,
type ListPagesStreamOption,
makeGetRequest as makeListPagesRequest,
} from "./api/pages/project.ts";
export {
makePostRequest as makeReplaceLinksRequest,
post as replaceLinks,
} from "./api/pages/project/replace/links.ts";
export {
get as searchForPages,
makeGetRequest as makeSearchForPagesRequest,
} from "./api/pages/project/search/query.ts";
export {
get as getLinks,
type GetLinksOptions,
list as readLinks,
makeGetRequest as makeGetLinksRequest,
} from "./api/pages/project/search/titles.ts";
export {
get as getPage,
type GetPageOption,
makeGetRequest as makeGetPageRequest,
} from "./api/pages/project/title.ts";
export {
get as getText,
type GetTextOption,
makeGetRequest as makeGetTextRequest,
} from "./api/pages/project/title/text.ts";
export {
get as getIcon,
type GetIconOption,
makeGetRequest as makeGetIconRequest,
} from "./api/pages/project/title/icon.ts";
export {
get as getProject,
makeGetRequest as makeGetProjectRequest,
} from "./api/projects/project.ts";
export {
get as getUser,
makeGetRequest as makeGetUserRequest,
} from "./api/users/me.ts";
1 change: 1 addition & 0 deletions api/pages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as project from "./pages/project.ts";
184 changes: 184 additions & 0 deletions api/pages/project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import type {
BasePage,
NotFoundError,
NotLoggedInError,
NotMemberError,
PageList,
} from "@cosense/types/rest";
import { type BaseOptions, setDefaults } from "../../util.ts";
import { cookie } from "../../rest/auth.ts";
import type {
ResponseOfEndpoint,
TargetedResponse,
} from "../../targeted_response.ts";
import {
type HTTPError,
makeError,
makeHTTPError,
type TypedError,
} from "../../error.ts";
import { pooledMap } from "@std/async/pool";
import { range } from "@core/iterutil/range";
import { flatten } from "@core/iterutil/async/flatten";

/** Options for {@linkcode get} */
export interface ListPagesOption<R extends Response | undefined>
extends BaseOptions<R> {
/** the sort of page list to return
*
* @default {"updated"}
*/
sort?:
| "updatedWithMe"
| "updated"
| "created"
| "accessed"
| "pageRank"
| "linked"
| "views"
| "title";
/** the index getting page list from
*
* @default {0}
*/
skip?: number;
/** threshold of the length of page list
*
* @default {100}
*/
limit?: number;
}

/** Constructs a request for the `/api/pages/:project` endpoint
*
* @param project The project name to list pages from
* @param options - Additional configuration options (sorting, pagination, etc.)
* @returns A {@linkcode Request} object for fetching pages data
*/
export const makeGetRequest = <R extends Response | undefined>(
project: string,
options?: ListPagesOption<R>,
): Request => {
const { sid, baseURL, sort, limit, skip } = setDefaults(
options ?? {},
);
const params = new URLSearchParams();
if (sort !== undefined) params.append("sort", sort);
if (limit !== undefined) params.append("limit", `${limit}`);
if (skip !== undefined) params.append("skip", `${skip}`);

return new Request(
`${baseURL}api/pages/${project}?${params}`,
sid ? { headers: { Cookie: cookie(sid) } } : undefined,
);
};

/** Lists pages from a specified project
*
* @param project The project name to list pages from
* @param options Configuration options for pagination and sorting
* @returns A {@linkcode Result}<{@linkcode unknown}, {@linkcode Error}> containing:
* - Success: The page data in JSON format
* - Error: One of several possible errors:
* - {@linkcode NotFoundError}: Page not found
* - {@linkcode NotLoggedInError}: Authentication required
* - {@linkcode NotMemberError}: User lacks access
*/
export const get = <R extends Response | undefined = Response>(
project: string,
options?: ListPagesOption<R>,
): Promise<
ResponseOfEndpoint<{
200: PageList;
404: NotFoundError;
401: NotLoggedInError;
403: NotMemberError;
}, R>
> =>
setDefaults(options ?? {}).fetch(
makeGetRequest(project, options),
) as Promise<
ResponseOfEndpoint<{
200: PageList;
404: NotFoundError;
401: NotLoggedInError;
403: NotMemberError;
}, R>
>;

/** Options for {@linkcode list} */
export interface ListPagesStreamOption<R extends Response | undefined>
extends ListPagesOption<R> {
/** The number of requests to make concurrently
*
* @default {3}
*/
poolLimit?: number;
}

/**
* Lists pages from a given `project` with pagination
*
* @param project The project name to list pages from
* @param options Configuration options for pagination and sorting
* @throws {HTTPError | TypedError<"NotLoggedInError" | "NotMemberError" | "NotFoundError">} If any requests in the pagination sequence fail
*/
export async function* list(
project: string,
options?: ListPagesStreamOption<Response>,
): AsyncGenerator<BasePage, void, unknown> {
const props = {
...(options ?? {}),
skip: options?.skip ?? 0,
limit: options?.limit ?? 100,
};
const response = await ensureResponse(await get(project, props));
const list = await response.json();
yield* list.pages;

const limit = list.limit;
const skip = list.skip + limit;
const times = Math.ceil((list.count - skip) / limit);

yield* flatten(
pooledMap(
options?.poolLimit ?? 3,
range(0, times - 1),
async (i) => {
const response = await ensureResponse(
await get(project, { ...props, skip: skip + i * limit, limit }),
);
const list = await response.json();
return list.pages;
},
),
);
}

const ensureResponse = async (
response: ResponseOfEndpoint<{
200: PageList;
404: NotFoundError;
401: NotLoggedInError;
403: NotMemberError;
}, Response>,
): Promise<TargetedResponse<200, PageList>> => {
switch (response.status) {
case 200:
return response;
case 401:
case 403:
case 404: {
const error = await response.json();
throw makeError(error.name, error.message) satisfies TypedError<
"NotLoggedInError" | "NotMemberError" | "NotFoundError"
>;
}
default:
throw makeHTTPError(response) satisfies HTTPError;
}
};

export * as replace from "./project/replace.ts";
export * as search from "./project/search.ts";
export * as title from "./project/title.ts";
1 change: 1 addition & 0 deletions api/pages/project/replace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as links from "./replace/links.ts";
85 changes: 85 additions & 0 deletions api/pages/project/replace/links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type {
NotFoundError,
NotLoggedInError,
NotMemberError,
} from "@cosense/types/rest";
import type { ResponseOfEndpoint } from "../../../../targeted_response.ts";
import { type ExtendedOptions, setDefaults } from "../../../../util.ts";
import { cookie } from "../../../../rest/auth.ts";
import { get } from "../../../users/me.ts";

/** Constructs a request for the `/api/pages/:project/replace/links` endpoint
*
* @param project - The project name where all links will be replaced
* @param from - The original link text to be replaced
* @param to - The new link text to replace with
* @param init - Additional configuration options
* @returns A {@linkcode Request} object for replacing links in `project`
*/
export const makePostRequest = <R extends Response | undefined>(
project: string,
from: string,
to: string,
init?: ExtendedOptions<R>,
): Request => {
const { sid, baseURL, csrf } = setDefaults(init ?? {});

return new Request(
`${baseURL}api/pages/${project}/replace/links`,
{
method: "POST",
headers: {
"Content-Type": "application/json;charset=utf-8",
"X-CSRF-TOKEN": csrf ?? "",
...(sid ? { Cookie: cookie(sid) } : {}),
},
body: JSON.stringify({ from, to }),
},
);
};

/** Retrieves JSON data for a specified page
*
* @param project - The project name where all links will be replaced
* @param from - The original link text to be replaced
* @param to - The new link text to replace with
* @param init - Additional configuration options
* @returns A {@linkcode Result}<{@linkcode unknown}, {@linkcode Error}> containing:
* - Success: The page data in JSON format
* - Error: One of several possible errors:
* - {@linkcode NotFoundError}: Page not found
* - {@linkcode NotLoggedInError}: Authentication required
* - {@linkcode NotMemberError}: User lacks access
*/
export const post = async <R extends Response | undefined = Response>(
project: string,
from: string,
to: string,
init?: ExtendedOptions<R>,
): Promise<
ResponseOfEndpoint<{
200: string;
404: NotFoundError;
401: NotLoggedInError;
403: NotMemberError;
}, R>
> => {
let { csrf, fetch, ...init2 } = setDefaults(init ?? {});

if (!csrf) {
const res = await get(init2);
if (!res.ok) return res;
csrf = (await res.json()).csrfToken;
}

return fetch(
makePostRequest(project, from, to, { csrf, ...init2 }),
) as Promise<
ResponseOfEndpoint<{
200: string;
404: NotFoundError;
401: NotLoggedInError;
403: NotMemberError;
}, R>
>;
};
2 changes: 2 additions & 0 deletions api/pages/project/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * as query from "./search/query.ts";
export * as titles from "./search/titles.ts";
63 changes: 63 additions & 0 deletions api/pages/project/search/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type {
NotFoundError,
NotLoggedInError,
NotMemberError,
SearchResult,
} from "@cosense/types/rest";
import type { ResponseOfEndpoint } from "../../../../targeted_response.ts";
import { type BaseOptions, setDefaults } from "../../../../util.ts";
import { cookie } from "../../../../rest/auth.ts";

/** Constructs a request for the `/api/pages/:project/search/query` endpoint
*
* @param project The name of the project to search within
* @param query The search query string to match against pages
* @param options - Additional configuration options
* @returns A {@linkcode Request} object for fetching page data
*/
export const makeGetRequest = <R extends Response | undefined>(
project: string,
query: string,
options?: BaseOptions<R>,
): Request => {
const { sid, baseURL } = setDefaults(options ?? {});

return new Request(
`${baseURL}api/pages/${project}/search/query?q=${
encodeURIComponent(query)
}`,
sid ? { headers: { Cookie: cookie(sid) } } : undefined,
);
};

/** Search for pages within a specific project
*
* @param project The name of the project to search within
* @param query The search query string to match against pages
* @param options Additional configuration options for the request
* @returns A {@linkcode Response} object containing the search results
*/
export const get = <R extends Response | undefined = Response>(
project: string,
query: string,
options?: BaseOptions<R>,
): Promise<
ResponseOfEndpoint<{
200: SearchResult;
404: NotFoundError;
401: NotLoggedInError;
403: NotMemberError;
422: { message: string };
}, R>
> =>
setDefaults(options ?? {}).fetch(
makeGetRequest(project, query, options),
) as Promise<
ResponseOfEndpoint<{
200: SearchResult;
404: NotFoundError;
401: NotLoggedInError;
403: NotMemberError;
422: { message: string };
}, R>
>;
Loading