Skip to content

Commit

Permalink
feat: add getInstallationUrl method (#542)
Browse files Browse the repository at this point in the history
  • Loading branch information
rpmccarter committed Jun 6, 2024
1 parent f285e8b commit 691ccfb
Show file tree
Hide file tree
Showing 5 changed files with 236 additions and 0 deletions.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- [`app.getInstallationOctokit`](#appgetinstallationoctokit)
- [`app.eachInstallation`](#appeachinstallation)
- [`app.eachRepository`](#appeachrepository)
- [`app.getInstallationUrl`](#appgetinstallationurl)
- [`app.webhooks`](#appwebhooks)
- [`app.oauth`](#appoauth)
- [Middlewares](#middlewares)
Expand Down Expand Up @@ -299,6 +300,22 @@ for await (const { octokit, repository } of app.eachRepository.iterator({ instal
await app.eachRepository({ installationId }, ({ octokit, repository }) => /* ... */)
```

### `app.getInstallationUrl`

```js
const installationUrl = await app.getInstallationUrl();
return res.redirect(installationUrl);
```

Optionally pass the ID of a GitHub organization or user to request installation on that specific target.

If the user will be sent to a redirect URL after installation (such as if you request user authorization during installation), you can also supply a `state` string that will be included in the query of the post-install redirect.

```js
const installationUrl = await app.getInstallationUrl({ state, target_id });
return res.redirect(installationUrl);
```

### `app.webhooks`

An [`@octokit/webhooks` instance](https://github.com/octokit/webhooks.js/#readme)
Expand Down
39 changes: 39 additions & 0 deletions src/get-installation-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { App } from "./index.js";
import type { GetInstallationUrlOptions } from "./types.js";

export function getInstallationUrlFactory(app: App) {
let installationUrlBasePromise: Promise<string> | undefined;

return async function getInstallationUrl(
options: GetInstallationUrlOptions = {},
) {
if (!installationUrlBasePromise) {
installationUrlBasePromise = getInstallationUrlBase(app);
}

const installationUrlBase = await installationUrlBasePromise;
const installationUrl = new URL(installationUrlBase);

if (options.target_id !== undefined) {
installationUrl.pathname += "/permissions";
installationUrl.searchParams.append(
"target_id",
options.target_id.toFixed(),
);
}

if (options.state !== undefined) {
installationUrl.searchParams.append("state", options.state);
}

return installationUrl.href;
};
}

async function getInstallationUrlBase(app: App) {
const { data: appInfo } = await app.octokit.request("GET /app");
if (!appInfo) {
throw new Error("[@octokit/app] unable to fetch metadata for app");
}
return `${appInfo.html_url}/installations/new`;
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
EachInstallationInterface,
EachRepositoryInterface,
GetInstallationOctokitInterface,
GetInstallationUrlInterface,
} from "./types.js";

// Export types required for the App class
Expand All @@ -18,13 +19,15 @@ export type {
EachInstallationInterface,
EachRepositoryInterface,
GetInstallationOctokitInterface,
GetInstallationUrlInterface,
} from "./types.js";

import { VERSION } from "./version.js";
import { webhooks } from "./webhooks.js";
import { eachInstallationFactory } from "./each-installation.js";
import { eachRepositoryFactory } from "./each-repository.js";
import { getInstallationOctokit } from "./get-installation-octokit.js";
import { getInstallationUrlFactory } from "./get-installation-url.js";

type Constructor<T> = new (...args: any[]) => T;

Expand Down Expand Up @@ -70,6 +73,7 @@ export class App<TOptions extends Options = Options> {
>;
eachInstallation: EachInstallationInterface<OctokitType<TOptions>>;
eachRepository: EachRepositoryInterface<OctokitType<TOptions>>;
getInstallationUrl: GetInstallationUrlInterface;
log: {
debug: (message: string, additionalInfo?: object) => void;
info: (message: string, additionalInfo?: object) => void;
Expand Down Expand Up @@ -150,6 +154,7 @@ export class App<TOptions extends Options = Options> {
this.eachRepository = eachRepositoryFactory(
this,
) as EachRepositoryInterface<OctokitType<TOptions>>;
this.getInstallationUrl = getInstallationUrlFactory(this);
}
}

Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,12 @@ export interface EachRepositoryInterface<O> {
export interface GetInstallationOctokitInterface<O> {
(installationId: number): Promise<O>;
}

export interface GetInstallationUrlOptions {
state?: string;
target_id?: number;
}

export interface GetInstallationUrlInterface {
(options?: GetInstallationUrlOptions): Promise<string>;
}
166 changes: 166 additions & 0 deletions test/get-installation-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Octokit } from "@octokit/core";
import fetchMock from "fetch-mock";
import MockDate from "mockdate";

const APP_ID = 1;
const PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH
lWu2/a1gB9JW5TBQ+AVpum9Okx7KfqkfBKL9mcHgSL0yWMdjMfNOqNtrQqKlN4kE
p6RD++7sGbzbfZ9arwrlD/HSDAWGdGGJTSOBM6pHehyLmSC3DJoR/CTu0vTGTWXQ
rO64Z8tyXQPtVPb/YXrcUhbBp8i72b9Xky0fD6PkEebOy0Ip58XVAn2UPNlNOSPS
ye+Qjtius0Md4Nie4+X8kwVI2Qjk3dSm0sw/720KJkdVDmrayeljtKBx6AtNQsSX
gzQbeMmiqFFkwrG1+zx6E7H7jqIQ9B6bvWKXGwIDAQABAoIBAD8kBBPL6PPhAqUB
K1r1/gycfDkUCQRP4DbZHt+458JlFHm8QL6VstKzkrp8mYDRhffY0WJnYJL98tr4
4tohsDbqFGwmw2mIaHjl24LuWXyyP4xpAGDpl9IcusjXBxLQLp2m4AKXbWpzb0OL
Ulrfc1ZooPck2uz7xlMIZOtLlOPjLz2DuejVe24JcwwHzrQWKOfA11R/9e50DVse
hnSH/w46Q763y4I0E3BIoUMsolEKzh2ydAAyzkgabGQBUuamZotNfvJoDXeCi1LD
8yNCWyTlYpJZJDDXooBU5EAsCvhN1sSRoaXWrlMSDB7r/E+aQyKua4KONqvmoJuC
21vSKeECgYEA7yW6wBkVoNhgXnk8XSZv3W+Q0xtdVpidJeNGBWnczlZrummt4xw3
xs6zV+rGUDy59yDkKwBKjMMa42Mni7T9Fx8+EKUuhVK3PVQyajoyQqFwT1GORJNz
c/eYQ6VYOCSC8OyZmsBM2p+0D4FF2/abwSPMmy0NgyFLCUFVc3OECpkCgYEA5OAm
I3wt5s+clg18qS7BKR2DuOFWrzNVcHYXhjx8vOSWV033Oy3yvdUBAhu9A1LUqpwy
Ma+unIgxmvmUMQEdyHQMcgBsVs10dR/g2xGjMLcwj6kn+xr3JVIZnbRT50YuPhf+
ns1ScdhP6upo9I0/sRsIuN96Gb65JJx94gQ4k9MCgYBO5V6gA2aMQvZAFLUicgzT
u/vGea+oYv7tQfaW0J8E/6PYwwaX93Y7Q3QNXCoCzJX5fsNnoFf36mIThGHGiHY6
y5bZPPWFDI3hUMa1Hu/35XS85kYOP6sGJjf4kTLyirEcNKJUWH7CXY+00cwvTkOC
S4Iz64Aas8AilIhRZ1m3eQKBgQCUW1s9azQRxgeZGFrzC3R340LL530aCeta/6FW
CQVOJ9nv84DLYohTVqvVowdNDTb+9Epw/JDxtDJ7Y0YU0cVtdxPOHcocJgdUGHrX
ZcJjRIt8w8g/s4X6MhKasBYm9s3owALzCuJjGzUKcDHiO2DKu1xXAb0SzRcTzUCn
7daCswKBgQDOYPZ2JGmhibqKjjLFm0qzpcQ6RPvPK1/7g0NInmjPMebP0K6eSPx0
9/49J6WTD++EajN7FhktUSYxukdWaCocAQJTDNYP0K88G4rtC2IYy5JFn9SWz5oh
x//0u+zd/R/QRUzLOw4N72/Hu+UG6MNt5iDZFCtapRaKt6OvSBwy8w==
-----END RSA PRIVATE KEY-----`;
const CLIENT_ID = "0123";
const CLIENT_SECRET = "0123secret";
const WEBHOOK_SECRET = "secret";

import { App } from "../src/index.ts";

describe("app.getInstallationUrl", () => {
let app: InstanceType<typeof App>;
let mock: typeof fetchMock;

beforeEach(() => {
MockDate.set(0);
mock = fetchMock.sandbox();

app = new App({
appId: APP_ID,
privateKey: PRIVATE_KEY,
oauth: {
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
},
webhooks: {
secret: WEBHOOK_SECRET,
},
Octokit: Octokit.defaults({
request: {
fetch: mock,
},
}),
});
});

test("throws when response is null", async () => {
mock.getOnce("path:/app", {
body: "null",
headers: { "Content-Type": "application/json" },
});

await expect(app.getInstallationUrl()).rejects.toThrow(
"[@octokit/app] unable to fetch metadata for app",
);

expect(mock.done()).toBe(true);
});

test("returns correct url", async () => {
mock.getOnce("path:/app", {
html_url: "https://github.com/apps/octokit",
});

const url = await app.getInstallationUrl();

expect(url).toEqual("https://github.com/apps/octokit/installations/new");
expect(mock.done()).toBe(true);
});

test("caches url", async () => {
mock.getOnce("path:/app", {
html_url: "https://github.com/apps/octokit",
});

const urls = await Promise.all([
app.getInstallationUrl(),
app.getInstallationUrl(),
app.getInstallationUrl(),
]);

expect(urls).toEqual(
new Array(3).fill("https://github.com/apps/octokit/installations/new"),
);
expect(mock.done()).toBe(true);
});

test("does not cache state", async () => {
mock.getOnce("path:/app", {
html_url: "https://github.com/apps/octokit",
});
const state = "abc123";

const urlWithoutState = await app.getInstallationUrl();
const urlWithState = await app.getInstallationUrl({ state });

expect(urlWithoutState).toEqual(
"https://github.com/apps/octokit/installations/new",
);
expect(urlWithState).toEqual(
`https://github.com/apps/octokit/installations/new?state=${state}`,
);
expect(mock.done()).toBe(true);
});

test("adds the url-encoded state string to the url", async () => {
mock.getOnce("path:/app", {
html_url: "https://github.com/apps/octokit",
});
const state = "abc123%/{";

const url = await app.getInstallationUrl({ state });

expect(url).toEqual(
`https://github.com/apps/octokit/installations/new?state=${encodeURIComponent(state)}`,
);
expect(mock.done()).toBe(true);
});

test("appends /permissions to the url when target_id present", async () => {
mock.getOnce("path:/app", {
html_url: "https://github.com/apps/octokit",
});
const target_id = 456;

const url = await app.getInstallationUrl({ target_id });

expect(url).toEqual(
`https://github.com/apps/octokit/installations/new/permissions?target_id=${target_id}`,
);
expect(mock.done()).toBe(true);
});

test("adds both state and target_id to the url", async () => {
mock.getOnce("path:/app", {
html_url: "https://github.com/apps/octokit",
});
const state = "abc123";
const target_id = 456;

const url = await app.getInstallationUrl({ state, target_id });

expect(url).toEqual(
`https://github.com/apps/octokit/installations/new/permissions?target_id=${target_id}&state=${state}`,
);
expect(mock.done()).toBe(true);
});
});

0 comments on commit 691ccfb

Please sign in to comment.