add cache layer

This commit is contained in:
Nathan Smith 2024-01-10 03:06:30 +00:00
parent 3021fb28f1
commit 49e3e2eb97
10 changed files with 334 additions and 209 deletions

View file

@ -1,206 +0,0 @@
import type { OnVerificationCallback } from "@octokit/auth-oauth-device/dist-types/types";
import type { RequestUrlParam } from "obsidian";
import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
import { request } from "@octokit/request";
import { requestUrl } from "obsidian";
const baseApi = "https://api.github.com";
interface ParsedUrl {
url: string;
host: string;
org?: string;
repo?: string;
issue?: number;
pr?: number;
code?: {
branch?: string;
path?: string;
};
commit?: string;
}
// TODO: Clean this file up, lots of junk in here
export async function githubRequest(config: RequestUrlParam, token?: string) {
if (!config.headers) {
config.headers = {};
}
config.headers.Accept = "application/vnd.github+json";
config.headers["X-GitHub-Api-Version"] = "2022-11-28";
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
try {
const response = await requestUrl(config);
return response;
} catch (err) {
console.error(err);
throw err;
}
}
export async function getIssue(org: string, repo: string, issue: number, token?: string) {
const result = await githubRequest({ url: `${baseApi}/repos/${org}/${repo}/issues/${issue}` }, token);
return result.json as RestEndpointMethodTypes["issues"]["get"]["response"]["data"];
}
export async function getPullRequest(org: string, repo: string, pr: number, token?: string) {
const result = await githubRequest(
{
url: `${baseApi}/repos/${org}/${repo}/pulls/${pr}`,
},
token,
);
return result.json as RestEndpointMethodTypes["pulls"]["get"]["response"]["data"];
}
export async function getCode(org: string, repo: string, path: string, branch: string, token?: string) {
const result = await githubRequest(
{
url: `${baseApi}/repos/${org}/${repo}/contents/${path}?ref=${branch}`,
},
token,
);
return result.json as RestEndpointMethodTypes["repos"]["getContent"]["response"]["data"];
}
export function parseUrl(urlString: string): ParsedUrl {
// Some potential URLs:
// https://github.com/nathonius/alloy-theme
// https://github.com/nathonius/obsidian-trello/issues/45
// https://github.com/nathonius/obsidian-trello/pull/54
// https://github.com/nathonius/obsidian-trello/blob/main/src/constants.ts
// https://github.com/nathonius/obsidian-trello/blob/markdown/src/constants.ts
// https://github.com/nathonius/obsidian-trello/blob/markdown/src/constants.ts#L44-L58
// https://github.com/nathonius/obsidian-trello/commit/7ea069dd0641441ec20fb194f50e746a21abbaf1
// https://github.com/nathonius/obsidian-trello/commit/7ea069dd0641441ec20fb194f50e746a21abbaf1#diff-8fa4b52909f895e8cda060d2035234e0a42ca2c7d3f8f8de1b35a056537bf199R35
const url = new URL(urlString);
const parsedUrl: ParsedUrl = { url: urlString, host: url.hostname };
const urlParts = url.pathname.split("/");
console.log(urlParts);
if (urlParts.length >= 4) {
switch (urlParts[3].toLowerCase()) {
case "issues":
if (urlParts[4]) {
const issueNumber = parseInt(urlParts[4], 10);
if (!isNaN(issueNumber)) {
parsedUrl.issue = issueNumber;
}
}
break;
case "pull":
if (urlParts[4]) {
const prNumber = parseInt(urlParts[4], 10);
if (!isNaN(prNumber)) {
parsedUrl.pr = prNumber;
}
}
break;
case "blob":
parsedUrl.code = {};
if (urlParts[4]) {
parsedUrl.code.branch = urlParts[4];
}
if (urlParts[5]) {
const pathParts = urlParts.slice(5);
parsedUrl.code.path = pathParts.join("/");
}
break;
case "commit":
if (urlParts[4]) {
parsedUrl.commit = urlParts.slice(4).join("/");
}
break;
}
}
if (urlParts.length >= 3) {
parsedUrl.repo = urlParts[2];
}
if (urlParts.length >= 2) {
parsedUrl.org = urlParts[1];
}
console.log(parsedUrl);
return parsedUrl;
}
async function doFetch(url: RequestInfo | URL, options?: RequestInit | undefined): Promise<Response> {
// Octokit always uses a url + options, not a Request object
if (typeof url !== "string") {
throw new Error("Something has gone horribly wrong and fetch has received unexpected arguments.");
}
if (options === undefined) {
throw new Error("No options given to fetch.");
}
const headers = getHeaders(options.headers);
const contentType = headers?.["Content-Type"];
const params: RequestUrlParam = {
url,
headers,
method: options.method,
body: getBody(options.body),
contentType,
};
const result = await requestUrl(params);
const partialResult = {
...result,
url,
headers: new Headers(result.headers),
arrayBuffer: () => Promise.resolve(result.arrayBuffer),
text: () => Promise.resolve(result.text),
json: () => Promise.resolve(result.json),
} as Omit<
Response,
"ok" | "body" | "statusText" | "redirected" | "type" | "clone" | "bodyUsed" | "blob" | "formData"
>;
return partialResult as Response;
}
function getHeaders(headers: HeadersInit | undefined): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
if (headers instanceof Headers) {
const result: Record<string, string> = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
if (Array.isArray(headers)) {
throw new Error("Got array headers, we don't know what to do with this yet.");
}
return headers;
}
function getBody(body: BodyInit | null | undefined): string | ArrayBuffer | undefined {
if (!body) {
return undefined;
}
if (body instanceof ArrayBuffer || typeof body === "string") {
return body;
}
console.warn(`Got an unknown body parameter type, trying to stringify it.`);
console.warn(body);
try {
return JSON.stringify(body);
} catch {
console.error("Could not stringify body parameter.");
return undefined;
}
}
export const auth = (verificationHandler: OnVerificationCallback) =>
createOAuthDeviceAuth({
clientType: "oauth-app",
clientId: "baf0370cb98e1387d244",
scopes: ["repo"],
onVerification: verificationHandler,
request: request.defaults({
request: { fetch: doFetch },
}),
});

55
src/github/api.ts Normal file
View file

@ -0,0 +1,55 @@
import type { CodeResponse, IssueResponse, PullResponse } from "./response";
import type { RequestUrlParam } from "obsidian";
import { requestUrl } from "obsidian";
const baseApi = "https://api.github.com";
async function githubRequest(config: RequestUrlParam, token?: string) {
if (!config.headers) {
config.headers = {};
}
config.headers.Accept = "application/vnd.github+json";
config.headers["X-GitHub-Api-Version"] = "2022-11-28";
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
try {
const response = await requestUrl(config);
return response;
} catch (err) {
console.error(err);
throw err;
}
}
async function getIssue(org: string, repo: string, issue: number, token?: string): Promise<IssueResponse> {
const result = await githubRequest({ url: `${baseApi}/repos/${org}/${repo}/issues/${issue}` }, token);
return result.json as IssueResponse;
}
async function getPullRequest(org: string, repo: string, pr: number, token?: string): Promise<PullResponse> {
const result = await githubRequest(
{
url: `${baseApi}/repos/${org}/${repo}/pulls/${pr}`,
},
token,
);
return result.json as PullResponse;
}
async function getCode(org: string, repo: string, path: string, branch: string, token?: string): Promise<CodeResponse> {
const result = await githubRequest(
{
url: `${baseApi}/repos/${org}/${repo}/contents/${path}?ref=${branch}`,
},
token,
);
return result.json as CodeResponse;
}
export const api = {
getIssue,
getPullRequest,
getCode,
};

83
src/github/auth.ts Normal file
View file

@ -0,0 +1,83 @@
import type { OnVerificationCallback } from "@octokit/auth-oauth-device/dist-types/types";
import type { RequestUrlParam } from "obsidian";
import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
import { request } from "@octokit/request";
import { requestUrl } from "obsidian";
function getHeaders(headers: HeadersInit | undefined): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
if (headers instanceof Headers) {
const result: Record<string, string> = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
if (Array.isArray(headers)) {
throw new Error("Got array headers, we don't know what to do with this yet.");
}
return headers;
}
function getBody(body: BodyInit | null | undefined): string | ArrayBuffer | undefined {
if (!body) {
return undefined;
}
if (body instanceof ArrayBuffer || typeof body === "string") {
return body;
}
console.warn(`Got an unknown body parameter type, trying to stringify it.`);
console.warn(body);
try {
return JSON.stringify(body);
} catch {
console.error("Could not stringify body parameter.");
return undefined;
}
}
async function doFetch(url: RequestInfo | URL, options?: RequestInit | undefined): Promise<Response> {
// Octokit always uses a url + options, not a Request object
if (typeof url !== "string") {
throw new Error("Something has gone horribly wrong and fetch has received unexpected arguments.");
}
if (options === undefined) {
throw new Error("No options given to fetch.");
}
const headers = getHeaders(options.headers);
const contentType = headers?.["Content-Type"];
const params: RequestUrlParam = {
url,
headers,
method: options.method,
body: getBody(options.body),
contentType,
};
const result = await requestUrl(params);
const partialResult = {
...result,
url,
headers: new Headers(result.headers),
arrayBuffer: () => Promise.resolve(result.arrayBuffer),
text: () => Promise.resolve(result.text),
json: () => Promise.resolve(result.json),
} as Omit<
Response,
"ok" | "body" | "statusText" | "redirected" | "type" | "clone" | "bodyUsed" | "blob" | "formData"
>;
return partialResult as Response;
}
export const auth = (verificationHandler: OnVerificationCallback) =>
createOAuthDeviceAuth({
clientType: "oauth-app",
clientId: "baf0370cb98e1387d244",
scopes: ["repo"],
onVerification: verificationHandler,
request: request.defaults({
request: { fetch: doFetch },
}),
});

82
src/github/cache.ts Normal file
View file

@ -0,0 +1,82 @@
import type { IssueResponse, PullResponse } from "./response";
class CacheEntry<T> {
constructor(
public value: T,
public created: Date = new Date(),
public ttl: number = 20,
) {}
get expired(): boolean {
const expiry = this.created.getTime() + this.ttl * 60 * 1000;
return new Date().getTime() > expiry;
}
}
class RepoCache {
public readonly issueCache: Record<number, CacheEntry<IssueResponse>> = {};
public readonly pullCache: Record<string, CacheEntry<PullResponse>> = {};
}
class OrgCache {
public readonly repos: Record<string, RepoCache> = {};
}
export class Cache {
public readonly orgs: Record<string, OrgCache> = {};
getIssue(org: string, repo: string, issue: number): IssueResponse | null {
const repoCache = this.getRepoCache(org, repo);
return this.getCacheValue(repoCache.issueCache[issue] ?? null);
}
setIssue(org: string, repo: string, issue: IssueResponse) {
const issueCache = this.getRepoCache(org, repo).issueCache;
const existingCache = issueCache[issue.id];
if (existingCache) {
const now = new Date();
existingCache.created = now;
existingCache.value = issue;
} else {
issueCache[issue.id] = new CacheEntry<IssueResponse>(issue);
}
}
getPullRequest(org: string, repo: string, pullRequest: number): PullResponse | null {
const repoCache = this.getRepoCache(org, repo);
return this.getCacheValue(repoCache.pullCache[pullRequest] ?? null);
}
setPullRequest(org: string, repo: string, pullRequest: PullResponse): void {
const pullCache = this.getRepoCache(org, repo).pullCache;
const existingCache = pullCache[pullRequest.id];
if (existingCache) {
const now = new Date();
existingCache.created = now;
existingCache.value = pullRequest;
} else {
pullCache[pullRequest.id] = new CacheEntry<PullResponse>(pullRequest);
}
}
private getRepoCache(org: string, repo: string) {
let orgCache = this.orgs[org];
if (!orgCache) {
orgCache = this.orgs[org] = new OrgCache();
}
let repoCache = orgCache.repos[repo];
if (!repoCache) {
repoCache = orgCache.repos[repo] = new RepoCache();
}
return repoCache;
}
private getCacheValue<T>(cacheEntry: CacheEntry<T> | null): T | null {
if (!cacheEntry || cacheEntry.expired) {
return null;
} else {
return cacheEntry.value;
}
}
}

33
src/github/github.ts Normal file
View file

@ -0,0 +1,33 @@
import type { IssueResponse, PullResponse } from "./response";
import { Cache } from "./cache";
import { api } from "./api";
const cache = new Cache();
export async function getIssue(org: string, repo: string, issue: number, token?: string): Promise<IssueResponse> {
const cachedValue = cache.getIssue(org, repo, issue);
if (cachedValue) {
return Promise.resolve(cachedValue);
}
const response = await api.getIssue(org, repo, issue, token);
cache.setIssue(org, repo, response);
return response;
}
export async function getPullRequest(
org: string,
repo: string,
pullRequest: number,
token?: string,
): Promise<PullResponse> {
const cachedValue = cache.getPullRequest(org, repo, pullRequest);
if (cachedValue) {
return Promise.resolve(cachedValue);
}
const response = await api.getPullRequest(org, repo, pullRequest, token);
cache.setPullRequest(org, repo, response);
return response;
}

5
src/github/response.ts Normal file
View file

@ -0,0 +1,5 @@
import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
export type IssueResponse = RestEndpointMethodTypes["issues"]["get"]["response"]["data"];
export type PullResponse = RestEndpointMethodTypes["pulls"]["get"]["response"]["data"];
export type CodeResponse = RestEndpointMethodTypes["repos"]["getContent"]["response"]["data"];

72
src/github/url-parse.ts Normal file
View file

@ -0,0 +1,72 @@
export interface ParsedUrl {
url: string;
host: string;
org?: string;
repo?: string;
issue?: number;
pr?: number;
code?: {
branch?: string;
path?: string;
};
commit?: string;
}
export function parseUrl(urlString: string): ParsedUrl {
// Some potential URLs:
// https://github.com/nathonius/alloy-theme
// https://github.com/nathonius/obsidian-trello/issues/45
// https://github.com/nathonius/obsidian-trello/pull/54
// https://github.com/nathonius/obsidian-trello/blob/main/src/constants.ts
// https://github.com/nathonius/obsidian-trello/blob/markdown/src/constants.ts
// https://github.com/nathonius/obsidian-trello/blob/markdown/src/constants.ts#L44-L58
// https://github.com/nathonius/obsidian-trello/commit/7ea069dd0641441ec20fb194f50e746a21abbaf1
// https://github.com/nathonius/obsidian-trello/commit/7ea069dd0641441ec20fb194f50e746a21abbaf1#diff-8fa4b52909f895e8cda060d2035234e0a42ca2c7d3f8f8de1b35a056537bf199R35
const url = new URL(urlString);
const parsedUrl: ParsedUrl = { url: urlString, host: url.hostname };
const urlParts = url.pathname.split("/");
if (urlParts.length >= 4) {
switch (urlParts[3].toLowerCase()) {
case "issues":
if (urlParts[4]) {
const issueNumber = parseInt(urlParts[4], 10);
if (!isNaN(issueNumber)) {
parsedUrl.issue = issueNumber;
}
}
break;
case "pull":
if (urlParts[4]) {
const prNumber = parseInt(urlParts[4], 10);
if (!isNaN(prNumber)) {
parsedUrl.pr = prNumber;
}
}
break;
case "blob":
parsedUrl.code = {};
if (urlParts[4]) {
parsedUrl.code.branch = urlParts[4];
}
if (urlParts[5]) {
const pathParts = urlParts.slice(5);
parsedUrl.code.path = pathParts.join("/");
}
break;
case "commit":
if (urlParts[4]) {
parsedUrl.commit = urlParts.slice(4).join("/");
}
break;
}
}
if (urlParts.length >= 3) {
parsedUrl.repo = urlParts[2];
}
if (urlParts.length >= 2) {
parsedUrl.org = urlParts[1];
}
return parsedUrl;
}

View file

@ -1,6 +1,7 @@
import { getIssue, getPullRequest, parseUrl } from "../github";
import { getIssue, getPullRequest } from "../github/github";
import { PluginSettings } from "../plugin";
import { parseUrl } from "../github/url-parse";
import { setIcon } from "obsidian";
// TODO: Split some of this out, there's no reason I should be getting the token when creating this element

View file

@ -3,7 +3,7 @@ import { GithubLinkPluginSettingsTab } from "./settings";
import { InlineRenderer } from "./inline/inline";
import { Plugin } from "obsidian";
import { createInlineViewPlugin } from "./inline/view-plugin";
import { getIssue } from "./github";
import { getIssue } from "./github/github";
export let PluginSettings: GithubLinkPluginSettings = { accounts: [] };

View file

@ -6,7 +6,7 @@ import { AuthModal } from "./auth-modal";
import type { GithubLinkPlugin } from "./plugin";
import { PluginSettings } from "./plugin";
import type { Verification } from "@octokit/auth-oauth-device/dist-types/types";
import { auth } from "./github";
import { auth } from "./github/auth";
export interface GithubAccount {
id: string;