diff --git a/README.md b/README.md index 10511b8..3332c4d 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,11 @@ const userRepos = await github.users.torvalds.repos(); - [ApiEndpoint](./docs/api/ApiEndpoint.md) - [GithubClient](./docs/api/GithubClient.md) -Available GitHub APIs: +Available GitHub APIs: - [repos](./docs/api/repos.md) - [users](./docs/api/users.md) +- [fetchRawFile](./docs/api/fetchRawFile.md) --- diff --git a/docs/api/fetchRawFile.md b/docs/api/fetchRawFile.md new file mode 100644 index 0000000..2284860 --- /dev/null +++ b/docs/api/fetchRawFile.md @@ -0,0 +1,106 @@ +# fetchRawFile + +Fetches the raw content of a file from a GitHub repository via `raw.githubusercontent.com`. + +```ts +import { fetchRawFile } from "@openally/github.sdk"; + +// Fetch file as plain text (default) +const content = await fetchRawFile("nodejs/node", "README.md"); + +// Fetch and parse as JSON +const pkg = await fetchRawFile<{ version: string }>("nodejs/node", "package.json", { + parser: "json" +}); + +// Fetch and parse with a custom parser +const lines = await fetchRawFile("nodejs/node", ".gitignore", { + parser: (content) => content.split("\n").filter(Boolean) +}); + +// Fetch from a specific branch or tag +const content = await fetchRawFile("nodejs/node", "README.md", { + ref: "v20.0.0" +}); + +// Fetch a private file with a token +const content = await fetchRawFile("myorg/private-repo", "config.json", { + token: process.env.GITHUB_TOKEN, + parser: "json" +}); +``` + +## Signature + +```ts +function fetchRawFile( + repository: `${string}/${string}`, + filePath: string, + options?: FetchRawFileOptions +): Promise; + +function fetchRawFile( + repository: `${string}/${string}`, + filePath: string, + options: FetchRawFileOptions & { parser: "json" } +): Promise; + +function fetchRawFile( + repository: `${string}/${string}`, + filePath: string, + options: FetchRawFileOptions & { parser: (content: string) => T } +): Promise; +``` + +## Parameters + +### `repository` + +Type: `` `${string}/${string}` `` + +The repository in `owner/repo` format (e.g. `"nodejs/node"`). + +### `filePath` + +Type: `string` + +Path to the file within the repository (e.g. `"src/index.ts"` or `"README.md"`). + +### `options` + +```ts +interface FetchRawFileOptions extends RequestConfig { + /** + * Branch, tag, or commit SHA. + * @default "HEAD" + */ + ref?: string; +} + +interface RequestConfig { + /** + * A personal access token is required to access private resources, + * and to increase the rate limit for unauthenticated requests. + */ + token?: string; + /** + * @default "@openally/github.sdk/1.0.0" + * @see https://docs.github.com/en/rest/using-the-rest-api/getting-started-with-the-rest-api?apiVersion=2022-11-28#user-agent + */ + userAgent?: string; +} + +``` + +## Return value + +- `Promise` when no `parser` is provided. +- `Promise` when `parser: "json"` or a custom parser function is provided. + +## Errors + +Throws an `Error` if the HTTP response is not `ok` (e.g. 404 for a missing file, 401 for an unauthorized request): + +``` +Failed to fetch raw file 'README.md' from nodejs/node@HEAD: HTTP 404 +``` diff --git a/src/api/rawFile.ts b/src/api/rawFile.ts new file mode 100644 index 0000000..3979d26 --- /dev/null +++ b/src/api/rawFile.ts @@ -0,0 +1,73 @@ +// Import Internal Dependencies +import { + DEFAULT_USER_AGENT, + GITHUB_RAW_API +} from "../constants.ts"; +import type { RequestConfig } from "../types.ts"; + +// CONSTANTS +const kDefaultRef = "HEAD"; + +export interface FetchRawFileOptions extends RequestConfig { + /** + * Branch, tag, or commit SHA. + * @default "HEAD" + */ + ref?: string; +} + +export type FetchRawFileClientOptions = Omit; +export type RawFileParser = "json" | ((content: string) => T); + +export function fetchRawFile( + repository: `${string}/${string}`, + filePath: string, + options?: FetchRawFileOptions & { parser?: undefined; } +): Promise; +export function fetchRawFile( + repository: `${string}/${string}`, + filePath: string, + options: FetchRawFileOptions & { parser: "json"; } +): Promise; +export function fetchRawFile( + repository: `${string}/${string}`, + filePath: string, + options: FetchRawFileOptions & { parser: (content: string) => T; } +): Promise; +export async function fetchRawFile( + repository: `${string}/${string}`, + filePath: string, + options: FetchRawFileOptions & { parser?: RawFileParser; } = {} +): Promise { + const { + ref = kDefaultRef, + token, + userAgent = DEFAULT_USER_AGENT, + parser + } = options; + + const url = new URL(`${repository}/${ref}/${filePath}`, GITHUB_RAW_API); + const headers: Record = { + "User-Agent": userAgent, + ...(typeof token === "string" ? { Authorization: `token ${token}` } : {}) + }; + + const response = await fetch(url, { headers }); + + if (!response.ok) { + throw new Error( + `Failed to fetch raw file '${filePath}' from ${repository}@${ref}: HTTP ${response.status}` + ); + } + + const content = await response.text(); + + if (parser === "json") { + return JSON.parse(content) as T; + } + if (typeof parser === "function") { + return parser(content); + } + + return content; +} diff --git a/src/class/ApiEndpoint.ts b/src/class/ApiEndpoint.ts index 012d9a4..1de8b90 100644 --- a/src/class/ApiEndpoint.ts +++ b/src/class/ApiEndpoint.ts @@ -1,26 +1,18 @@ // Import Internal Dependencies import { HttpLinkParser } from "./HttpLinkParser.ts"; +import { + DEFAULT_USER_AGENT, + GITHUB_API +} from "../constants.ts"; +import type { RequestConfig } from "../types.ts"; -// CONSTANTS -const kGithubURL = new URL("https://api.github.com/"); - -export class ApiEndpointOptions { +export interface ApiEndpointOptions extends RequestConfig { /** * By default, the raw response from the GitHub API is returned as-is. * You can provide a custom extractor function to transform the raw response * into an array of type T. */ extractor?: (raw: any) => T[]; - /** - * A personal access token is required to access private resources, - * and to increase the rate limit for unauthenticated requests. - */ - token?: string; - /** - * @default "@openally/github.sdk/1.0.0" - * @see https://docs.github.com/en/rest/using-the-rest-api/getting-started-with-the-rest-api?apiVersion=2022-11-28#user-agent - */ - userAgent?: string; } export class ApiEndpoint { @@ -36,7 +28,7 @@ export class ApiEndpoint { options: ApiEndpointOptions = {} ) { const { - userAgent = "@openally/github.sdk/1.0.0", + userAgent = DEFAULT_USER_AGENT, token, extractor = ((raw) => raw as T[]) } = options; @@ -75,8 +67,8 @@ export class ApiEndpoint { }; const url = this.#nextURL === null ? - new URL(this.#apiEndpoint, kGithubURL) : - new URL(this.#nextURL, kGithubURL); + new URL(this.#apiEndpoint, GITHUB_API) : + new URL(this.#nextURL, GITHUB_API); const response = await fetch( url, { headers } diff --git a/src/class/GithubClient.ts b/src/class/GithubClient.ts index 760ba4c..5b5ec33 100644 --- a/src/class/GithubClient.ts +++ b/src/class/GithubClient.ts @@ -7,25 +7,56 @@ import { createReposProxy, type ReposProxy } from "../api/repos.ts"; +import { + fetchRawFile, + type FetchRawFileClientOptions, + type RawFileParser +} from "../api/rawFile.ts"; +import type { RequestConfig } from "../types.ts"; -export interface GithubClientOptions { - token?: string; - userAgent?: string; -} +export interface GithubClientOptions extends RequestConfig {} export class GithubClient { readonly users: UsersProxy; readonly repos: ReposProxy; + #config: RequestConfig; constructor( options: GithubClientOptions = {} ) { - const config = { + this.#config = { token: options.token, userAgent: options.userAgent }; - this.users = createUsersProxy(config); - this.repos = createReposProxy(config); + this.users = createUsersProxy(this.#config); + this.repos = createReposProxy(this.#config); + } + + fetchRawFile( + repository: `${string}/${string}`, + filePath: string, + options?: FetchRawFileClientOptions & { parser?: undefined; } + ): Promise; + fetchRawFile( + repository: `${string}/${string}`, + filePath: string, + options: FetchRawFileClientOptions & { parser: "json"; } + ): Promise; + fetchRawFile( + repository: `${string}/${string}`, + filePath: string, + options: FetchRawFileClientOptions & { parser: (content: string) => T; } + ): Promise; + fetchRawFile( + repository: `${string}/${string}`, + filePath: string, + options: FetchRawFileClientOptions & { parser?: RawFileParser; } = {} + ): Promise { + return fetchRawFile( + repository, + filePath, + { ...this.#config, ...options } as any + ); } } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..afb3a79 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_USER_AGENT = "@openally/github.sdk/1.0.0"; +export const GITHUB_API = new URL("https://api.github.com/"); +export const GITHUB_RAW_API = new URL("https://raw.githubusercontent.com/"); diff --git a/src/index.ts b/src/index.ts index ff68c0f..9acaffc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,9 @@ export * from "./api/users.ts"; export * from "./api/repos.ts"; +export { + fetchRawFile, + type FetchRawFileOptions +} from "./api/rawFile.ts"; export * from "./class/GithubClient.ts"; export type { RequestConfig } from "./types.ts"; diff --git a/src/types.ts b/src/types.ts index 2e46c7f..7d8b6b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,15 @@ import type { Endpoints } from "@octokit/types"; export interface RequestConfig { + /** + * A personal access token is required to access private resources, + * and to increase the rate limit for unauthenticated requests. + */ token?: string; + /** + * @default "@openally/github.sdk/1.0.0" + * @see https://docs.github.com/en/rest/using-the-rest-api/getting-started-with-the-rest-api?apiVersion=2022-11-28#user-agent + */ userAgent?: string; } diff --git a/test/rawFile.spec.ts b/test/rawFile.spec.ts new file mode 100644 index 0000000..512e38d --- /dev/null +++ b/test/rawFile.spec.ts @@ -0,0 +1,382 @@ +// Import Node.js Dependencies +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +// Import Third-party Dependencies +import { MockAgent, setGlobalDispatcher, getGlobalDispatcher, type Dispatcher } from "undici"; + +// Import Internal Dependencies +import { fetchRawFile } from "../src/api/rawFile.ts"; +import { GithubClient } from "../src/class/GithubClient.ts"; + +// CONSTANTS +const kRawGithubOrigin = "https://raw.githubusercontent.com"; + +describe("fetchRawFile()", () => { + let mockAgent: MockAgent; + let originalDispatcher: Dispatcher; + + beforeEach(() => { + originalDispatcher = getGlobalDispatcher(); + mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + setGlobalDispatcher(mockAgent); + }); + + afterEach(async() => { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + }); + + describe("raw text (no parser)", () => { + it("should return the raw file content as a string", async() => { + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/HEAD/README.md", method: "GET" }) + .reply(200, "# Hello World\n"); + + const result = await fetchRawFile("octocat/hello-world", "README.md"); + + assert.equal(result, "# Hello World\n"); + }); + + it("should default to ref HEAD", async() => { + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/HEAD/README.md", method: "GET" }) + .reply(200, "content"); + + await assert.doesNotReject( + fetchRawFile("octocat/hello-world", "README.md") + ); + }); + + it("should use the provided ref", async() => { + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/main/README.md", method: "GET" }) + .reply(200, "content on main"); + + const result = await fetchRawFile("octocat/hello-world", "README.md", { ref: "main" }); + + assert.equal(result, "content on main"); + }); + + it("should support file paths with directory segments", async() => { + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/HEAD/src/index.ts", method: "GET" }) + .reply(200, "export {};\n"); + + const result = await fetchRawFile("octocat/hello-world", "src/index.ts"); + + assert.equal(result, "export {};\n"); + }); + }); + + describe("parser: \"json\"", () => { + it("should parse and return the file content as a JSON object", async() => { + const pkg = { name: "hello-world", version: "1.0.0" }; + + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/HEAD/package.json", method: "GET" }) + .reply(200, JSON.stringify(pkg)); + + const result = await fetchRawFile<{ name: string; version: string; }>( + "octocat/hello-world", + "package.json", + { parser: "json" } + ); + + assert.deepEqual(result, pkg); + }); + + it("should return unknown by default when parser is \"json\"", async() => { + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/HEAD/data.json", method: "GET" }) + .reply(200, JSON.stringify([1, 2, 3])); + + const result = await fetchRawFile("octocat/hello-world", "data.json", { parser: "json" }); + + assert.deepEqual(result, [1, 2, 3]); + }); + }); + + describe("custom parser function", () => { + it("should apply a custom parser to the raw content", async() => { + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/HEAD/VERSION", method: "GET" }) + .reply(200, "2.3.1\n"); + + const result = await fetchRawFile( + "octocat/hello-world", + "VERSION", + { parser: (content) => content.trim() } + ); + + assert.equal(result, "2.3.1"); + }); + + it("should pass the raw string content to the parser", async() => { + const rawContent = "key=value\nfoo=bar\n"; + const captured: string[] = []; + + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/HEAD/.env", method: "GET" }) + .reply(200, rawContent); + + await fetchRawFile( + "octocat/hello-world", + ".env", + { + parser: (content) => { + captured.push(content); + + return content; + } + } + ); + + assert.equal(captured[0], rawContent); + }); + }); + + describe("headers", () => { + it("should send the default User-Agent header", async() => { + mockAgent + .get(kRawGithubOrigin) + .intercept({ + path: "/octocat/hello-world/HEAD/README.md", + method: "GET", + headers: { "user-agent": "@openally/github.sdk/1.0.0" } + }) + .reply(200, "content"); + + await assert.doesNotReject( + fetchRawFile("octocat/hello-world", "README.md") + ); + }); + + it("should send a custom User-Agent when provided", async() => { + mockAgent + .get(kRawGithubOrigin) + .intercept({ + path: "/octocat/hello-world/HEAD/README.md", + method: "GET", + headers: { "user-agent": "my-app/2.0" } + }) + .reply(200, "content"); + + await assert.doesNotReject( + fetchRawFile("octocat/hello-world", "README.md", { userAgent: "my-app/2.0" }) + ); + }); + + it("should send the Authorization header when a token is provided", async() => { + mockAgent + .get(kRawGithubOrigin) + .intercept({ + path: "/octocat/hello-world/HEAD/README.md", + method: "GET", + headers: { authorization: "token secret123" } + }) + .reply(200, "content"); + + await assert.doesNotReject( + fetchRawFile("octocat/hello-world", "README.md", { token: "secret123" }) + ); + }); + + it("should succeed without an Authorization header when no token is provided", async() => { + // undici's MockAgent will reject the request if the intercepted headers + // do not match — here we assert no `authorization` key is present by + // confirming the request succeeds against an interceptor that has no + // header constraint at all (the positive case with a token is separately tested). + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/HEAD/README.md", method: "GET" }) + .reply(200, "content"); + + await assert.doesNotReject( + fetchRawFile("octocat/hello-world", "README.md") + ); + }); + }); + + describe("error handling", () => { + it("should throw on a 404 response", async() => { + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/HEAD/missing.txt", method: "GET" }) + .reply(404, "Not Found"); + + await assert.rejects( + fetchRawFile("octocat/hello-world", "missing.txt"), + (err: Error) => { + assert.ok(err.message.includes("404")); + + return true; + } + ); + }); + + it("should throw on a 500 response", async() => { + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/HEAD/README.md", method: "GET" }) + .reply(500, "Internal Server Error"); + + await assert.rejects( + fetchRawFile("octocat/hello-world", "README.md"), + (err: Error) => { + assert.ok(err.message.includes("500")); + + return true; + } + ); + }); + + it("should include the repository, ref, and file path in the error message", async() => { + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/HEAD/missing.txt", method: "GET" }) + .reply(404, "Not Found"); + + await assert.rejects( + fetchRawFile("octocat/hello-world", "missing.txt"), + (err: Error) => { + assert.ok(err.message.includes("missing.txt")); + assert.ok(err.message.includes("octocat/hello-world")); + assert.ok(err.message.includes("HEAD")); + + return true; + } + ); + }); + }); +}); + +describe("GithubClient.fetchRawFile()", () => { + let mockAgent: MockAgent; + let originalDispatcher: Dispatcher; + + beforeEach(() => { + originalDispatcher = getGlobalDispatcher(); + mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + setGlobalDispatcher(mockAgent); + }); + + afterEach(async() => { + await mockAgent.close(); + setGlobalDispatcher(originalDispatcher); + }); + + it("should fetch raw content using the client's token", async() => { + const client = new GithubClient({ token: "clienttoken" }); + + mockAgent + .get(kRawGithubOrigin) + .intercept({ + path: "/octocat/hello-world/HEAD/README.md", + method: "GET", + headers: { authorization: "token clienttoken" } + }) + .reply(200, "# Hello"); + + await assert.doesNotReject( + client.fetchRawFile("octocat/hello-world", "README.md") + ); + }); + + it("should fetch raw content using the client's userAgent", async() => { + const client = new GithubClient({ userAgent: "my-client/1.0" }); + + mockAgent + .get(kRawGithubOrigin) + .intercept({ + path: "/octocat/hello-world/HEAD/README.md", + method: "GET", + headers: { "user-agent": "my-client/1.0" } + }) + .reply(200, "# Hello"); + + await assert.doesNotReject( + client.fetchRawFile("octocat/hello-world", "README.md") + ); + }); + + it("should parse JSON when parser is \"json\"", async() => { + const client = new GithubClient(); + const pkg = { name: "hello-world", version: "1.0.0" }; + + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/HEAD/package.json", method: "GET" }) + .reply(200, JSON.stringify(pkg)); + + const result = await client.fetchRawFile<{ name: string; version: string; }>( + "octocat/hello-world", + "package.json", + { parser: "json" } + ); + + assert.deepEqual(result, pkg); + }); + + it("should apply a custom parser function", async() => { + const client = new GithubClient(); + + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/main/VERSION", method: "GET" }) + .reply(200, "3.0.0\n"); + + const result = await client.fetchRawFile( + "octocat/hello-world", + "VERSION", + { ref: "main", parser: (s) => s.trim() } + ); + + assert.equal(result, "3.0.0"); + }); + + it("should use the ref option when provided", async() => { + const client = new GithubClient(); + + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/v2.0.0/CHANGELOG.md", method: "GET" }) + .reply(200, "## v2.0.0\n"); + + const result = await client.fetchRawFile( + "octocat/hello-world", + "CHANGELOG.md", + { ref: "v2.0.0" } + ); + + assert.equal(result, "## v2.0.0\n"); + }); + + it("should throw when the file is not found", async() => { + const client = new GithubClient(); + + mockAgent + .get(kRawGithubOrigin) + .intercept({ path: "/octocat/hello-world/HEAD/missing.txt", method: "GET" }) + .reply(404, "Not Found"); + + await assert.rejects( + client.fetchRawFile("octocat/hello-world", "missing.txt"), + (err: Error) => { + assert.ok(err.message.includes("404")); + + return true; + } + ); + }); +});