From 95d06cf1bb31ce90aabd4cd08ee728ff5a79ab41 Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Wed, 6 Oct 2021 13:44:28 -0700 Subject: [PATCH] feat: `endpoint` package (#37) --- package-lock.json | 52 ++ package.json | 11 +- packages/endpoint/README.md | 538 ++++++++++++++++++ packages/endpoint/index.d.ts | 3 + packages/endpoint/index.js | 4 + packages/endpoint/index.test-d.ts | 46 ++ packages/endpoint/lib/defaults.js | 20 + .../endpoint/lib/endpoint-with-defaults.js | 6 + packages/endpoint/lib/merge.js | 34 ++ packages/endpoint/lib/parse.js | 101 ++++ .../endpoint/lib/util/add-query-parameters.js | 20 + .../lib/util/extract-url-variable-names.js | 15 + packages/endpoint/lib/util/lowercase-keys.js | 10 + packages/endpoint/lib/util/merge-deep.js | 16 + packages/endpoint/lib/util/omit.js | 8 + .../lib/util/remove-undefined-properties.js | 8 + packages/endpoint/lib/util/url-template.js | 185 ++++++ packages/endpoint/lib/version.js | 1 + packages/endpoint/lib/with-defaults.js | 15 + packages/endpoint/package.json | 42 ++ packages/endpoint/test/defaults.test.js | 149 +++++ packages/endpoint/test/endpoint.test.js | 456 +++++++++++++++ packages/endpoint/test/merge.test.js | 131 +++++ packages/endpoint/test/parse.test.js | 59 ++ packages/types-rest-api-ghes-3.0/index.d.ts | 2 + packages/types-rest-api-ghes-3.1/index.d.ts | 2 + packages/types-rest-api-ghes-3.2/index.d.ts | 2 + packages/types-rest-api-github.ae/index.d.ts | 2 + packages/types-rest-api/operation.d.ts | 22 +- packages/types/endpoint.d.ts | 232 ++++++++ packages/types/index.d.ts | 15 +- .../templates/diff-to-ghes.d.ts.template | 2 + .../diff-to-github.com.d.ts.template | 2 + 33 files changed, 2199 insertions(+), 12 deletions(-) create mode 100644 packages/endpoint/README.md create mode 100644 packages/endpoint/index.d.ts create mode 100644 packages/endpoint/index.js create mode 100644 packages/endpoint/index.test-d.ts create mode 100644 packages/endpoint/lib/defaults.js create mode 100644 packages/endpoint/lib/endpoint-with-defaults.js create mode 100644 packages/endpoint/lib/merge.js create mode 100644 packages/endpoint/lib/parse.js create mode 100644 packages/endpoint/lib/util/add-query-parameters.js create mode 100644 packages/endpoint/lib/util/extract-url-variable-names.js create mode 100644 packages/endpoint/lib/util/lowercase-keys.js create mode 100644 packages/endpoint/lib/util/merge-deep.js create mode 100644 packages/endpoint/lib/util/omit.js create mode 100644 packages/endpoint/lib/util/remove-undefined-properties.js create mode 100644 packages/endpoint/lib/util/url-template.js create mode 100644 packages/endpoint/lib/version.js create mode 100644 packages/endpoint/lib/with-defaults.js create mode 100644 packages/endpoint/package.json create mode 100644 packages/endpoint/test/defaults.test.js create mode 100644 packages/endpoint/test/endpoint.test.js create mode 100644 packages/endpoint/test/merge.test.js create mode 100644 packages/endpoint/test/parse.test.js create mode 100644 packages/types/endpoint.d.ts diff --git a/package-lock.json b/package-lock.json index 915c1a0..d1f1829 100644 --- a/package-lock.json +++ b/package-lock.json @@ -157,6 +157,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@octokit-next/endpoint": { + "resolved": "packages/endpoint", + "link": true + }, "node_modules/@octokit-next/request": { "resolved": "packages/request", "link": true @@ -2511,6 +2515,11 @@ "node": ">=0.8.0" } }, + "node_modules/universal-user-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.0.tgz", + "integrity": "sha512-glvNHZsMnw7t6wWim1dLlRcYhjjshFF7evftNpd6JZeLy0B7+d9vcnRsGrYmSdl4/e2sMib7wYBJ+M23jQwu2g==" + }, "node_modules/uvu": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.1.tgz", @@ -2736,6 +2745,28 @@ "@octokit-next/types": "0.0.0-development" } }, + "packages/endpoint": { + "name": "@octokit-next/endpoint", + "version": "0.0.0-development", + "license": "MIT", + "dependencies": { + "@octokit-next/types": "0.0.0-development", + "is-plain-obj": "^4.0.0", + "type-fest": "^2.3.4", + "universal-user-agent": "^7.0.0" + } + }, + "packages/endpoint/node_modules/is-plain-obj": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.0.0.tgz", + "integrity": "sha512-NXRbBtUdBioI73y/HmOhogw/U5msYPC9DAtGkJXeFcFWSFZw0mCUsPxk/snTuJHzNKA8kLBK4rH97RMB1BfCXw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/request": { "name": "@octokit-next/request", "version": "0.0.0-development", @@ -3007,6 +3038,22 @@ "@octokit-next/types": "0.0.0-development" } }, + "@octokit-next/endpoint": { + "version": "file:packages/endpoint", + "requires": { + "@octokit-next/types": "0.0.0-development", + "is-plain-obj": "^4.0.0", + "type-fest": "*", + "universal-user-agent": "^7.0.0" + }, + "dependencies": { + "is-plain-obj": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.0.0.tgz", + "integrity": "sha512-NXRbBtUdBioI73y/HmOhogw/U5msYPC9DAtGkJXeFcFWSFZw0mCUsPxk/snTuJHzNKA8kLBK4rH97RMB1BfCXw==" + } + } + }, "@octokit-next/request": { "version": "file:packages/request", "requires": { @@ -4825,6 +4872,11 @@ "dev": true, "optional": true }, + "universal-user-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.0.tgz", + "integrity": "sha512-glvNHZsMnw7t6wWim1dLlRcYhjjshFF7evftNpd6JZeLy0B7+d9vcnRsGrYmSdl4/e2sMib7wYBJ+M23jQwu2g==" + }, "uvu": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.1.tgz", diff --git a/package.json b/package.json index 42ff159..b8df4b2 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,12 @@ "types": "./index.d.ts", "exports": "./index.js", "scripts": { - "test": "npm run test:code && npm run test:dts && npm run test:ts && npm run test:coverage", + "test": "npm run test:workspaces && npm run test:code && npm run test:dts && npm run test:ts && npm run test:coverage", "test:code": "c8 node test.js", "test:coverage": "c8 check-coverage", "test:dts": "for d in tests/js/* ; do echo $d; tsd $d; if [ $? -eq 0 ]; then echo ok; else exit 1; fi; done", - "test:ts": "for d in tests/ts/*/tsconfig.json ; do echo $d; tsc -p $d; if [ $? -eq 0 ]; then echo ok; else exit 1; fi; done" + "test:ts": "for d in tests/ts/*/tsconfig.json ; do echo $d; tsc -p $d; if [ $? -eq 0 ]; then echo ok; else exit 1; fi; done", + "test:workspaces": "npm test --workspaces --if-present" }, "renovate": { "extends": [ @@ -49,6 +50,12 @@ "pkgRoot": "packages/core" } ], + [ + "@semantic-release/npm", + { + "pkgRoot": "packages/endpoint" + } + ], [ "@semantic-release/npm", { diff --git a/packages/endpoint/README.md b/packages/endpoint/README.md new file mode 100644 index 0000000..a44b68c --- /dev/null +++ b/packages/endpoint/README.md @@ -0,0 +1,538 @@ +# endpoint.js + +> Turns GitHub REST API endpoints into generic request options + +[![@latest](https://img.shields.io/npm/v/@octokit-next/endpoint.svg)](https://www.npmjs.com/package/@octokit-next/endpoint) +[![Build Status](https://github.com/octokit-next/endpoint.js/workflows/Test/badge.svg)](https://github.com/octokit-next/endpoint.js/actions/workflows/test.yml?query=branch%3Amaster) + +`@octokit-next/endpoint` combines [GitHub REST API routes](https://docs.github.com/rest) with parameters and turns them into generic request options that can be used in any request library. + + + + + +- [Usage](#usage) +- [API](#api) + - [`endpoint(route, options)` or `endpoint(options)`](#endpointroute-options-or-endpointoptions) + - [`endpoint.defaults()`](#endpointdefaults) + - [`endpoint.DEFAULTS`](#endpointdefaults) + - [`endpoint.merge(route, options)` or `endpoint.merge(options)`](#endpointmergeroute-options-or-endpointmergeoptions) + - [`endpoint.parse()`](#endpointparse) +- [Types](#types) +- [Special cases](#special-cases) + - [The `data` parameter – set request body directly](#the-data-parameter-%E2%80%93-set-request-body-directly) + - [Set parameters for both the URL/query and the request body](#set-parameters-for-both-the-urlquery-and-the-request-body) +- [LICENSE](#license) + + + +## Usage + + + + + + + +
+Browsers + +Load @octokit-next/endpoint directly from cdn.skypack.dev + +```html + +``` + +
+Node + + +Install with npm install @octokit-next/endpoint + +```js +import { endpoint } from "@octokit-next/endpoint"; +``` + +
+Deno + + +Load @octokit-next/endpoint directly from cdn.skypack.dev, including types. + +```js +import { endpoint } from "https://cdn.skypack.dev/octokit?dts"; +``` + +
+ +Example for [List organization repositories](https://docs.github.com/rest/reference/repos#list-organization-repositories) + +```js +const requestOptions = endpoint("GET /orgs/{org}/repos", { + headers: { + authorization: "token 0000000000000000000000000000000000000001", + }, + org: "octokit", + type: "private", +}); +``` + +The resulting `requestOptions` looks as follows + +```json +{ + "method": "GET", + "url": "https://api.github.com/orgs/octokit/repos?type=private", + "headers": { + "accept": "application/vnd.github.v3+json", + "authorization": "token 0000000000000000000000000000000000000001", + "user-agent": "octokit-next/endpoint.js v1.2.3" + } +} +``` + +You can pass `requestOptions` to common request libraries + +```js +const { url, ...options } = requestOptions; +// https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch +fetch(url, options); +// https://github.com/sindresorhus/got +got[options.method](url, options); +// https://github.com/axios/axios +axios(requestOptions); +``` + +For `PUT/POST` endpoints with request body parameters, the code is slightly different + +```js +const { url, data, ...options } = requestOptions; +// https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch +fetch(url, { ...options, body: JSON.stringify(data) }); +// https://github.com/sindresorhus/got +got[options.method](url, { ...options, json: data }); +// https://github.com/axios/axios +axios(requestOptions); +``` + +## API + +### `endpoint(route, options)` or `endpoint(options)` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ name + + type + + description +
+ route + + String + + If set, it has to be a string consisting of URL and the request method, e.g., GET /orgs/{org}. If it’s set to a URL, only the method defaults to GET. +
+ options.method + + String + + Required unless route is set. Any supported http verb. Defaults to GET. +
+ options.url + + String + + Required unless route is set. A path or full URL which may contain :variable or {variable} placeholders, + e.g., /orgs/{org}/repos. +
+ options.baseUrl + + String + + Defaults to https://api.github.com. +
+ options.headers + + Object + + Custom headers. Passed headers are merged with defaults:
+ headers['user-agent'] defaults to octokit-endpoint.js/1.2.3 (where 1.2.3 is the released version).
+ headers['accept'] defaults to application/vnd.github.v3+json.
+
+ options.mediaType.format + + String + + Media type param, such as raw, diff, or text+json. See Media Types. Setting options.mediaType.format will amend the headers.accept value. +
+ options.mediaType.previews + + Array of Strings + + Name of previews, such as mercy, symmetra, or scarlet-witch. See API Previews. If options.mediaType.previews was set as default, the new previews will be merged into the default ones. Setting options.mediaType.previews will amend the headers.accept value. options.mediaType.previews will be merged with an existing array set using .defaults(). +
+ options.data + + Any + + Set request body directly instead of setting it to JSON based on additional parameters. See "The data parameter" below. +
+ options.request + + Object + + Pass custom meta information for the request. The request object will be returned as is. +
+ +All other options will be passed depending on the `method` and `url` options. + +1. If the option key has a placeholder in the `url`, it will be used as the replacement. For example, if the passed options are `{url: '/orgs/{org}/repos', org: 'foo'}` the returned `options.url` is `https://api.github.com/orgs/foo/repos`. +2. If the `method` is `GET` or `HEAD`, the option is passed as a query parameter. +3. Otherwise, the parameter is passed in the request body as a JSON key. + +**Result** + +`endpoint()` is a synchronous method and returns an object with the following keys: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ key + + type + + description +
methodStringThe http method. Always lowercase.
urlStringThe url with placeholders replaced with passed parameters.
headersObjectAll header names are lowercased.
bodyAnyThe request body if one is present. Only for PATCH, POST, PUT, DELETE requests.
requestObjectRequest meta option, it will be returned as it was passed into endpoint()
+ +### `endpoint.defaults()` + +Override or set default options. Example: + +```js +const myEndpoint = endpoint.defaults({ + baseUrl: "https://github-enterprise.acme-inc.com/api/v3", + headers: { + "user-agent": "myApp/1.2.3", + authorization: `token 0000000000000000000000000000000000000001`, + }, +}); + +const options = myEndpoint(`GET /orgs/{org}/repos`, { + org: "my-project", + per_page: 100, +}); +// { +// "method": "GET", +// "url": "https://api.github.com/orgs/my-project/repos?per_page=100", +// "headers": { +// "accept": "application/vnd.github.v3+json", +// "authorization": "token 0000000000000000000000000000000000000001", +// "user-agent": "myApp/1.2.3" +// } +// } +``` + +You can call `.defaults()` again on the returned method, the defaults will cascade. + +```js +const myEndpointWithToken2 = myEndpoint.defaults({ + headers: { + authorization: `token 0000000000000000000000000000000000000002`, + }, +}); + +const options2 = myEndpoint(`GET /orgs/{org}/repos`, { + org: "my-project", + per_page: 100, +}); +// { +// "method": "GET", +// "url": "https://api.github.com/orgs/my-project/repos?per_page=100", +// "headers": { +// "accept": "application/vnd.github.v3+json", +// "authorization": "token 0000000000000000000000000000000000000002", +// "user-agent": "myApp/1.2.3" +// } +// } +``` + +### `endpoint.DEFAULTS` + +The current default options. + +```js +endpoint.DEFAULTS.baseUrl; // https://api.github.com +const myEndpoint = endpoint.defaults({ + baseUrl: "https://github-enterprise.acme-inc.com/api/v3", +}); +myEndpoint.DEFAULTS.baseUrl; // https://github-enterprise.acme-inc.com/api/v3 +``` + +### `endpoint.merge(route, options)` or `endpoint.merge(options)` + +Get the defaulted endpoint options, but without parsing them into request options: + +```js +const myProjectEndpoint = endpoint.defaults({ + baseUrl: "https://github-enterprise.acme-inc.com/api/v3", + headers: { + "user-agent": "myApp/1.2.3", + }, + org: "my-project", +}); +myProjectEndpoint.merge("GET /orgs/{org}/repos", { + headers: { + authorization: `token 0000000000000000000000000000000000000001`, + }, + org: "my-secret-project", + type: "private", +}); + +// { +// baseUrl: 'https://github-enterprise.acme-inc.com/api/v3', +// method: 'GET', +// url: '/orgs/{org}/repos', +// headers: { +// accept: 'application/vnd.github.v3+json', +// authorization: `token 0000000000000000000000000000000000000001`, +// 'user-agent': 'myApp/1.2.3' +// }, +// org: 'my-secret-project', +// type: 'private' +// } +``` + +### `endpoint.parse()` + +Stateless method to turn endpoint options into request options. Calling +`endpoint(options)` is the same as calling `endpoint.parse(endpoint.merge(options))`. + +## Types + +`@octokit-next/endpoint` supports types for all REST API endpoints across all supported targets (github.com, GitHub AE, GitHub Enterprise Server). + +In order to take advantage of the types, you have to install the `@octokit-next/types-rest-api*` packages for the platform(s) you want to target. + +For example, to get types for all of github.com's REST API endpoints, use `@octokit-next/types-rest-api`. + +```js +/// + +import { endpoint } from "@octokit-next/endpoint"; + +endpoint(""); +// Set cursor in the route argument and press `Ctrl + Enter` to get a type ahead for all 700+ REST API endpoints + +const requestOptions = endpoint("GET /orgs/{org}/repos", { org: "octokit" }); +// requestOptions.method is now typed as `"GET"` instead of `string` +// requestOptions.url is now typed as `"/orgs/{org}/repos"` instead of `string` +// requestOptions.data does not exist on types. +``` + +To support GitHub Enterprise Server 3.0 and all new versions, import `@octokit-next/types-rest-api-ghes-3.0` and set the request version: + +```js +/// + +import { endpoint } from "@octokit-next/endpoint"; + +endpoint("", { + request: { + version: "ghes-3.0", + }, +}); +// Set cursor in the route argument and press `Ctrl + Enter` to get a type ahead for all GHES 3.0 REST API endpoints + +const requestOptions = endpoint("GET /admin/users/{username}", { + request: { + version: "ghes-3.0", + }, + username: "octocat", +}); +// requestOptions.method is now typed as `"GET"` instead of `string` +// requestOptions.url is now typed as `"/admin/users/{username}"` instead of `string` +// requestOptions.data does not exist on types. +``` + +Types in the `@octokit-next/types-rest-api-ghes` packages are additive. So you can set `request.version` to `ghes-3.1` and `ghes-3.2` as well. + +The version can be set using `endpoint.defaults()` as well. You can override the version in each `endpoint()` call. + +```js +/// + +import { endpoint } from "@octokit-next/endpoint"; + +const ghes30endpoint = endpoint.defaults({ + request: { + version: "ghes-3.0", + }, +}); + +endpoint(""); +// Set cursor in the route argument and press `Ctrl + Enter` to get a type ahead for all GHES 3.0 REST API endpoints +``` + +If you need your script to work across github.com and a minimal GitHub Enterprise Server version, you can use any of the `@octokit-next/types-rest-api-ghes-*-compatible` packages. + +```js +/// + +import { endpoint } from "@octokit-next/endpoint"; + +const ghes30endpoint = endpoint.defaults({ + request: { + version: "ghes-3.0", + }, +}); + +endpoint(""); +// Set cursor in the route argument and press `Ctrl + Enter` to get a type ahead for all REST API endpoints +// that exist in both github.com and GitHub Enterprise Server 3.0 +``` + +## Special cases + + + +### The `data` parameter – set request body directly + +Some endpoints such as [Render a Markdown document in raw mode](https://docs.github.com/rest/reference/markdown#render-a-markdown-document-in-raw-mode) don’t have parameters that are sent as request body keys, instead, the request body needs to be set directly. In these cases, set the `data` parameter. + +```js +const options = endpoint("POST /markdown/raw", { + data: "Hello world github/linguist#1 **cool**, and #1!", + headers: { + accept: "text/html;charset=utf-8", + "content-type": "text/plain", + }, +}); + +// options is +// { +// method: 'post', +// url: 'https://api.github.com/markdown/raw', +// headers: { +// accept: 'text/html;charset=utf-8', +// 'content-type': 'text/plain', +// 'user-agent': userAgent +// }, +// body: 'Hello world github/linguist#1 **cool**, and #1!' +// } +``` + +### Set parameters for both the URL/query and the request body + +There are API endpoints that accept both query parameters as well as a body. In that case, you need to add the query parameters as templates to `options.url`, as defined in the [RFC 6570 URI Template specification](https://tools.ietf.org/html/rfc6570). + +Example + +```js +endpoint( + "POST https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets{?name,label}", + { + name: "example.zip", + label: "short description", + headers: { + "content-type": "text/plain", + "content-length": 14, + authorization: `token 0000000000000000000000000000000000000001`, + }, + data: "Hello, world!", + } +); +``` + +## LICENSE + +[MIT](LICENSE) diff --git a/packages/endpoint/index.d.ts b/packages/endpoint/index.d.ts new file mode 100644 index 0000000..52d9bb7 --- /dev/null +++ b/packages/endpoint/index.d.ts @@ -0,0 +1,3 @@ +import { EndpointInterface } from "@octokit-next/types"; + +export declare const endpoint: EndpointInterface; diff --git a/packages/endpoint/index.js b/packages/endpoint/index.js new file mode 100644 index 0000000..1b72d42 --- /dev/null +++ b/packages/endpoint/index.js @@ -0,0 +1,4 @@ +import { withDefaults } from "./lib/with-defaults.js"; +import { DEFAULTS } from "./lib/defaults.js"; + +export const endpoint = withDefaults(null, DEFAULTS); diff --git a/packages/endpoint/index.test-d.ts b/packages/endpoint/index.test-d.ts new file mode 100644 index 0000000..930ebe5 --- /dev/null +++ b/packages/endpoint/index.test-d.ts @@ -0,0 +1,46 @@ +import { expectType } from "tsd"; + +import "@octokit-next/types-rest-api-ghes-3.2"; + +import { endpoint } from "./index.js"; + +export function readmeExample() { + const requestOptions = endpoint("GET /orgs/{org}/repos", { + headers: { + authorization: "token 0000000000000000000000000000000000000001", + }, + org: "octokit", + type: "private", + }); + expectType<"GET">(requestOptions.method); + expectType<"/orgs/{org}/repos">(requestOptions.url); + expectType(requestOptions.headers["accept"]); + expectType(requestOptions.headers["user-agent"]); + expectType(requestOptions.headers["authorization"]); + + // @ts-expect-error - `.data` is not set for a GET operation + requestOptions.data; +} + +export function ghesExample() { + const requestOptions = endpoint("PATCH /admin/organizations/{org}", { + request: { + version: "ghes-3.2", + }, + headers: { + authorization: "token 0000000000000000000000000000000000000001", + }, + org: "octokit", + login: "new-octokit", + }); + + expectType<"PATCH">(requestOptions.method); + expectType<"/admin/organizations/{org}">(requestOptions.url); + expectType(requestOptions.headers["accept"]); + expectType(requestOptions.headers["user-agent"]); + expectType(requestOptions.headers["authorization"]); + + expectType<{ + login: string; + }>(requestOptions.data); +} diff --git a/packages/endpoint/lib/defaults.js b/packages/endpoint/lib/defaults.js new file mode 100644 index 0000000..e6936dc --- /dev/null +++ b/packages/endpoint/lib/defaults.js @@ -0,0 +1,20 @@ +import { getUserAgent } from "universal-user-agent"; + +import { VERSION } from "./version.js"; + +const userAgent = `octokit-endpoint.js/${VERSION} ${getUserAgent()}`; + +// DEFAULTS has all properties set that EndpointOptions has, except url. +// So we use RequestParameters and add method as additional required property. +export const DEFAULTS = { + method: "GET", + baseUrl: "https://api.github.com", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": userAgent, + }, + mediaType: { + format: "", + previews: [], + }, +}; diff --git a/packages/endpoint/lib/endpoint-with-defaults.js b/packages/endpoint/lib/endpoint-with-defaults.js new file mode 100644 index 0000000..efba7d1 --- /dev/null +++ b/packages/endpoint/lib/endpoint-with-defaults.js @@ -0,0 +1,6 @@ +import { merge } from "./merge.js"; +import { parse } from "./parse.js"; + +export function endpointWithDefaults(defaults, route, options) { + return parse(merge(defaults, route, options)); +} diff --git a/packages/endpoint/lib/merge.js b/packages/endpoint/lib/merge.js new file mode 100644 index 0000000..606d55f --- /dev/null +++ b/packages/endpoint/lib/merge.js @@ -0,0 +1,34 @@ +import { lowercaseKeys } from "./util/lowercase-keys.js"; +import { mergeDeep } from "./util/merge-deep.js"; +import { removeUndefinedProperties } from "./util/remove-undefined-properties.js"; + +export function merge(defaults, route, options) { + if (typeof route === "string") { + let [method, url] = route.split(" "); + options = Object.assign(url ? { method, url } : { url: method }, options); + } else { + options = Object.assign({}, route); + } + + // lowercase header names before merging with defaults to avoid duplicates + options.headers = lowercaseKeys(options.headers); + + // remove properties with undefined values before merging + removeUndefinedProperties(options); + removeUndefinedProperties(options.headers); + + const mergedOptions = mergeDeep(defaults || {}, options); + + // mediaType.previews arrays are merged, instead of overwritten + if (defaults && defaults.mediaType.previews.length) { + mergedOptions.mediaType.previews = defaults.mediaType.previews + .filter((preview) => !mergedOptions.mediaType.previews.includes(preview)) + .concat(mergedOptions.mediaType.previews); + } + + mergedOptions.mediaType.previews = mergedOptions.mediaType.previews.map( + (preview) => preview.replace(/-preview/, "") + ); + + return mergedOptions; +} diff --git a/packages/endpoint/lib/parse.js b/packages/endpoint/lib/parse.js new file mode 100644 index 0000000..991d681 --- /dev/null +++ b/packages/endpoint/lib/parse.js @@ -0,0 +1,101 @@ +import { addQueryParameters } from "./util/add-query-parameters.js"; +import { extractUrlVariableNames } from "./util/extract-url-variable-names.js"; +import { omit } from "./util/omit.js"; +import { parseUrl } from "./util/url-template.js"; + +export function parse(options) { + // https://fetch.spec.whatwg.org/#methods + let method = options.method.toUpperCase(); + + // replace :varname with {varname} to make it RFC 6570 compatible + let url = (options.url || "/").replace(/:([a-z]\w+)/g, "{$1}"); + let headers = Object.assign({}, options.headers); + let body; + let parameters = omit(options, [ + "method", + "baseUrl", + "url", + "headers", + "request", + "mediaType", + ]); + + // extract variable names from URL to calculate remaining variables later + const urlVariableNames = extractUrlVariableNames(url); + + url = parseUrl(url).expand(parameters); + + if (!/^http/.test(url)) { + url = options.baseUrl + url; + } + + const omittedParameters = Object.keys(options) + .filter((option) => urlVariableNames.includes(option)) + .concat("baseUrl"); + const remainingParameters = omit(parameters, omittedParameters); + + const isBinaryRequest = /application\/octet-stream/i.test(headers.accept); + + if (!isBinaryRequest) { + if (options.mediaType.format) { + // e.g. application/vnd.github.v3+json => application/vnd.github.v3.raw + headers.accept = headers.accept + .split(/,/) + .map((preview) => + preview.replace( + /application\/vnd(\.\w+)(\.v3)?(\.\w+)?(\+json)?$/, + `application/vnd$1$2.${options.mediaType.format}` + ) + ) + .join(","); + } + + if (options.mediaType.previews.length) { + const previewsFromAcceptHeader = + headers.accept.match(/[\w-]+(?=-preview)/g) || []; + headers.accept = previewsFromAcceptHeader + .concat(options.mediaType.previews) + .map((preview) => { + const format = options.mediaType.format + ? `.${options.mediaType.format}` + : "+json"; + return `application/vnd.github.${preview}-preview${format}`; + }) + .join(","); + } + } + + // for GET/HEAD requests, set URL query parameters from remaining parameters + // for PATCH/POST/PUT/DELETE requests, set request body from remaining parameters + if (["GET", "HEAD"].includes(method)) { + url = addQueryParameters(url, remainingParameters); + } else { + if ("data" in remainingParameters) { + body = remainingParameters.data; + } else { + if (Object.keys(remainingParameters).length) { + body = remainingParameters; + } else { + headers["content-length"] = 0; + } + } + } + + // default content-type for JSON if body is set + if (!headers["content-type"] && typeof body !== "undefined") { + headers["content-type"] = "application/json; charset=utf-8"; + } + + // GitHub expects 'content-length: 0' header for PUT/PATCH requests without body. + // fetch does not allow to set `content-length` header, but we can set body to an empty string + if (["PATCH", "PUT"].includes(method) && typeof body === "undefined") { + body = ""; + } + + // Only return body/request keys if present + return Object.assign( + { method, url, headers }, + typeof body !== "undefined" ? { body } : null, + options.request ? { request: options.request } : null + ); +} diff --git a/packages/endpoint/lib/util/add-query-parameters.js b/packages/endpoint/lib/util/add-query-parameters.js new file mode 100644 index 0000000..a1bd76d --- /dev/null +++ b/packages/endpoint/lib/util/add-query-parameters.js @@ -0,0 +1,20 @@ +export function addQueryParameters(url, parameters) { + const separator = /\?/.test(url) ? "&" : "?"; + const names = Object.keys(parameters); + + if (names.length === 0) { + return url; + } + + const query = names + .map((name) => { + if (name === "q") { + return "q=" + parameters.q.split("+").map(encodeURIComponent).join("+"); + } + + return `${name}=${encodeURIComponent(parameters[name])}`; + }) + .join("&"); + + return url + separator + query; +} diff --git a/packages/endpoint/lib/util/extract-url-variable-names.js b/packages/endpoint/lib/util/extract-url-variable-names.js new file mode 100644 index 0000000..adf1b3f --- /dev/null +++ b/packages/endpoint/lib/util/extract-url-variable-names.js @@ -0,0 +1,15 @@ +const urlVariableRegex = /\{[^}]+\}/g; + +function removeNonChars(variableName) { + return variableName.replace(/^\W+|\W+$/g, "").split(/,/); +} + +export function extractUrlVariableNames(url) { + const matches = url.match(urlVariableRegex); + + if (!matches) { + return []; + } + + return matches.map(removeNonChars).reduce((a, b) => a.concat(b), []); +} diff --git a/packages/endpoint/lib/util/lowercase-keys.js b/packages/endpoint/lib/util/lowercase-keys.js new file mode 100644 index 0000000..efb2797 --- /dev/null +++ b/packages/endpoint/lib/util/lowercase-keys.js @@ -0,0 +1,10 @@ +export function lowercaseKeys(object) { + if (!object) { + return {}; + } + + return Object.keys(object).reduce((newObj, key) => { + newObj[key.toLowerCase()] = object[key]; + return newObj; + }, {}); +} diff --git a/packages/endpoint/lib/util/merge-deep.js b/packages/endpoint/lib/util/merge-deep.js new file mode 100644 index 0000000..5f4d0a5 --- /dev/null +++ b/packages/endpoint/lib/util/merge-deep.js @@ -0,0 +1,16 @@ +import isPlainObject from "is-plain-obj"; + +export function mergeDeep(defaults, options) { + const result = Object.assign({}, defaults); + + Object.keys(options).forEach((key) => { + if (isPlainObject(options[key])) { + if (!(key in defaults)) Object.assign(result, { [key]: options[key] }); + else result[key] = mergeDeep(defaults[key], options[key]); + } else { + Object.assign(result, { [key]: options[key] }); + } + }); + + return result; +} diff --git a/packages/endpoint/lib/util/omit.js b/packages/endpoint/lib/util/omit.js new file mode 100644 index 0000000..e76b221 --- /dev/null +++ b/packages/endpoint/lib/util/omit.js @@ -0,0 +1,8 @@ +export function omit(object, keysToOmit) { + return Object.keys(object) + .filter((option) => !keysToOmit.includes(option)) + .reduce((obj, key) => { + obj[key] = object[key]; + return obj; + }, {}); +} diff --git a/packages/endpoint/lib/util/remove-undefined-properties.js b/packages/endpoint/lib/util/remove-undefined-properties.js new file mode 100644 index 0000000..6e28019 --- /dev/null +++ b/packages/endpoint/lib/util/remove-undefined-properties.js @@ -0,0 +1,8 @@ +export function removeUndefinedProperties(obj) { + for (const key in obj) { + if (obj[key] === undefined) { + delete obj[key]; + } + } + return obj; +} diff --git a/packages/endpoint/lib/util/url-template.js b/packages/endpoint/lib/util/url-template.js new file mode 100644 index 0000000..e273ad2 --- /dev/null +++ b/packages/endpoint/lib/util/url-template.js @@ -0,0 +1,185 @@ +// Based on https://github.com/bramstein/url-template, licensed under BSD +// TODO: create separate package. +// +// Copyright (c) 2012-2014, Bram Stein +// All rights reserved. + +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: + +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// 3. The name of the author may not be used to endorse or promote products +// derived from this software without specific prior written permission. + +// THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR IMPLIED +// WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +// EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +// INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +// BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +// OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +// NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +// EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +/* c8 ignore start */ + +function encodeReserved(str) { + return str + .split(/(%[0-9A-Fa-f]{2})/g) + .map(function (part) { + if (!/%[0-9A-Fa-f]/.test(part)) { + part = encodeURI(part).replace(/%5B/g, "[").replace(/%5D/g, "]"); + } + return part; + }) + .join(""); +} + +function encodeUnreserved(str) { + return encodeURIComponent(str).replace(/[!'()*]/g, function (c) { + return "%" + c.charCodeAt(0).toString(16).toUpperCase(); + }); +} + +function encodeValue(operator, value, key) { + value = + operator === "+" || operator === "#" + ? encodeReserved(value) + : encodeUnreserved(value); + + if (key) { + return encodeUnreserved(key) + "=" + value; + } else { + return value; + } +} + +function isDefined(value) { + return value !== undefined && value !== null; +} + +function isKeyOperator(operator) { + return operator === ";" || operator === "&" || operator === "?"; +} + +function getValues(context, operator, key, modifier) { + var value = context[key], + result = []; + + if (isDefined(value) && value !== "") { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + value = value.toString(); + + if (modifier && modifier !== "*") { + value = value.substring(0, parseInt(modifier, 10)); + } + + result.push( + encodeValue(operator, value, isKeyOperator(operator) ? key : "") + ); + } else { + if (modifier === "*") { + if (Array.isArray(value)) { + value.filter(isDefined).forEach(function (value) { + result.push( + encodeValue(operator, value, isKeyOperator(operator) ? key : "") + ); + }); + } else { + Object.keys(value).forEach(function (k) { + if (isDefined(value[k])) { + result.push(encodeValue(operator, value[k], k)); + } + }); + } + } else { + const tmp = []; + + if (Array.isArray(value)) { + value.filter(isDefined).forEach(function (value) { + tmp.push(encodeValue(operator, value)); + }); + } else { + Object.keys(value).forEach(function (k) { + if (isDefined(value[k])) { + tmp.push(encodeUnreserved(k)); + tmp.push(encodeValue(operator, value[k].toString())); + } + }); + } + + if (isKeyOperator(operator)) { + result.push(encodeUnreserved(key) + "=" + tmp.join(",")); + } else if (tmp.length !== 0) { + result.push(tmp.join(",")); + } + } + } + } else { + if (operator === ";") { + if (isDefined(value)) { + result.push(encodeUnreserved(key)); + } + } else if (value === "" && (operator === "&" || operator === "?")) { + result.push(encodeUnreserved(key) + "="); + } else if (value === "") { + result.push(""); + } + } + return result; +} + +export function parseUrl(template) { + return { + expand: expand.bind(null, template), + }; +} + +function expand(template, context) { + var operators = ["+", "#", ".", "/", ";", "?", "&"]; + + return template.replace( + /\{([^\{\}]+)\}|([^\{\}]+)/g, + function (_, expression, literal) { + if (expression) { + let operator = ""; + const values = []; + + if (operators.indexOf(expression.charAt(0)) !== -1) { + operator = expression.charAt(0); + expression = expression.substr(1); + } + + expression.split(/,/g).forEach(function (variable) { + var tmp = /([^:\*]*)(?::(\d+)|(\*))?/.exec(variable); + values.push(getValues(context, operator, tmp[1], tmp[2] || tmp[3])); + }); + + if (operator && operator !== "+") { + var separator = ","; + + if (operator === "?") { + separator = "&"; + } else if (operator !== "#") { + separator = operator; + } + return (values.length !== 0 ? operator : "") + values.join(separator); + } else { + return values.join(","); + } + } else { + return encodeReserved(literal); + } + } + ); +} diff --git a/packages/endpoint/lib/version.js b/packages/endpoint/lib/version.js new file mode 100644 index 0000000..86383b1 --- /dev/null +++ b/packages/endpoint/lib/version.js @@ -0,0 +1 @@ +export const VERSION = "0.0.0-development"; diff --git a/packages/endpoint/lib/with-defaults.js b/packages/endpoint/lib/with-defaults.js new file mode 100644 index 0000000..db7473a --- /dev/null +++ b/packages/endpoint/lib/with-defaults.js @@ -0,0 +1,15 @@ +import { endpointWithDefaults } from "./endpoint-with-defaults.js"; +import { merge } from "./merge.js"; +import { parse } from "./parse.js"; + +export function withDefaults(oldDefaults, newDefaults) { + const DEFAULTS = merge(oldDefaults, newDefaults); + const endpoint = endpointWithDefaults.bind(null, DEFAULTS); + + return Object.assign(endpoint, { + DEFAULTS, + defaults: withDefaults.bind(null, DEFAULTS), + merge: merge.bind(null, DEFAULTS), + parse, + }); +} diff --git a/packages/endpoint/package.json b/packages/endpoint/package.json new file mode 100644 index 0000000..42bcc03 --- /dev/null +++ b/packages/endpoint/package.json @@ -0,0 +1,42 @@ +{ + "name": "@octokit-next/endpoint", + "version": "0.0.0-development", + "publishConfig": { + "access": "public" + }, + "type": "module", + "description": "Turns REST API endpoints into generic request options", + "exports": "./index.js", + "types": "./index.d.ts", + "scripts": { + "test": "npm run test:code && npm run test:types", + "test:code": "c8 uvu test", + "test:types": "tsd" + }, + "repository": { + "type": "git", + "url": "https://github.com/octokit/octokit-next.js.git", + "directory": "packages/endpoint" + }, + "homepage": "https://github.com/octokit/octokit-next.js/tree/main/packages/endpoint#readme", + "keywords": [ + "octokit", + "github", + "api" + ], + "author": "Gregor Martynus (https://github.com/gr2m)", + "license": "MIT", + "dependencies": { + "@octokit-next/types": "0.0.0-development", + "is-plain-obj": "^4.0.0", + "type-fest": "^2.3.4", + "universal-user-agent": "^7.0.0" + }, + "c8": { + "check-coverage": true, + "lines": 100, + "functions": 100, + "branches": 100, + "statements": 100 + } +} diff --git a/packages/endpoint/test/defaults.test.js b/packages/endpoint/test/defaults.test.js new file mode 100644 index 0000000..2fb32e7 --- /dev/null +++ b/packages/endpoint/test/defaults.test.js @@ -0,0 +1,149 @@ +import { suite } from "uvu"; +import * as assert from "uvu/assert"; + +import { endpoint } from "../index.js"; + +const test = suite("endpoint.defaults()"); + +test("is a function", () => { + assert.instance( + endpoint.defaults, + Function, + "endpoint.defaults() is a function" + ); +}); + +test("README example", () => { + const myEndpoint = endpoint.defaults({ + baseUrl: "https://github-enterprise.acme-inc.com/api/v3", + headers: { + "user-agent": "myApp/1.2.3", + authorization: `token 0000000000000000000000000000000000000001`, + }, + org: "my-project", + per_page: 100, + }); + + const options = myEndpoint(`GET /orgs/{org}/repos`); + + assert.equal(options, { + method: "GET", + url: "https://github-enterprise.acme-inc.com/api/v3/orgs/my-project/repos?per_page=100", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "myApp/1.2.3", + authorization: `token 0000000000000000000000000000000000000001`, + }, + }); +}); + +test("repeated defaults", () => { + const myProjectEndpoint = endpoint.defaults({ + baseUrl: "https://github-enterprise.acme-inc.com/api/v3", + headers: { + "user-agent": "myApp/1.2.3", + }, + org: "my-project", + }); + const myProjectEndpointWithAuth = myProjectEndpoint.defaults({ + headers: { + authorization: `token 0000000000000000000000000000000000000001`, + }, + }); + + const options = myProjectEndpointWithAuth(`GET /orgs/{org}/repos`); + + assert.equal(options, { + method: "GET", + url: "https://github-enterprise.acme-inc.com/api/v3/orgs/my-project/repos", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "myApp/1.2.3", + authorization: `token 0000000000000000000000000000000000000001`, + }, + }); +}); + +test(".DEFAULTS", () => { + assert.equal(endpoint.DEFAULTS.baseUrl, "https://api.github.com"); + const myEndpoint = endpoint.defaults({ + baseUrl: "https://github-enterprise.acme-inc.com/api/v3", + }); + assert.equal( + myEndpoint.DEFAULTS.baseUrl, + "https://github-enterprise.acme-inc.com/api/v3" + ); +}); + +test(".defaults() merges options but does not yet parse", () => { + const myEndpoint = endpoint.defaults({ + url: "/orgs/{org}", + org: "test1", + }); + assert.equal(myEndpoint.DEFAULTS.url, "/orgs/{org}"); + assert.equal(myEndpoint.DEFAULTS.org, "test1"); + const myEndpoint2 = myEndpoint.defaults({ + url: "/orgs/{org}", + org: "test2", + }); + assert.equal(myEndpoint2.DEFAULTS.url, "/orgs/{org}"); + assert.equal(myEndpoint2.DEFAULTS.org, "test2"); +}); + +test(".defaults() sets mediaType.format", () => { + const myEndpoint = endpoint.defaults({ + mediaType: { + format: "raw", + }, + }); + assert.equal(myEndpoint.DEFAULTS.mediaType, { + format: "raw", + previews: [], + }); +}); + +test(".defaults() merges mediaType.previews", () => { + const myEndpoint = endpoint.defaults({ + mediaType: { + previews: ["foo"], + }, + }); + const myEndpoint2 = myEndpoint.defaults({ + mediaType: { + previews: ["bar"], + }, + }); + + assert.equal(myEndpoint.DEFAULTS.mediaType, { + format: "", + previews: ["foo"], + }); + assert.equal(myEndpoint2.DEFAULTS.mediaType, { + format: "", + previews: ["foo", "bar"], + }); +}); + +test('.defaults() merges mediaType.previews with "-preview" suffix', () => { + const myEndpoint = endpoint.defaults({ + mediaType: { + previews: ["foo-preview"], + }, + }); + const myEndpoint2 = myEndpoint.defaults({ + mediaType: { + previews: ["bar-preview"], + }, + }); + + assert.equal(myEndpoint.DEFAULTS.mediaType, { + format: "", + previews: ["foo"], + }); + assert.equal(myEndpoint2.DEFAULTS.mediaType, { + format: "", + previews: ["foo", "bar"], + }); +}); + +test.run(); diff --git a/packages/endpoint/test/endpoint.test.js b/packages/endpoint/test/endpoint.test.js new file mode 100644 index 0000000..5e5c161 --- /dev/null +++ b/packages/endpoint/test/endpoint.test.js @@ -0,0 +1,456 @@ +import { Agent } from "http"; + +import { suite } from "uvu"; +import * as assert from "uvu/assert"; +import { getUserAgent } from "universal-user-agent"; + +import { endpoint } from "../index.js"; +import { VERSION } from "../lib/version.js"; + +const userAgent = `octokit-endpoint.js/${VERSION} ${getUserAgent()}`; + +const test = suite("endpoint()"); + +test("is a function", () => { + assert.instance(endpoint, Function, "endpoint is a function"); +}); + +test("README example", () => { + const options = endpoint({ + method: "GET", + url: "/orgs/{org}/repos", + org: "octokit", + type: "private", + }); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/orgs/octokit/repos?type=private", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": userAgent, + }, + }); +}); + +test("Pass route string as first argument", () => { + const options = endpoint("GET /orgs/{org}/repos", { + org: "octokit", + type: "private", + }); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/orgs/octokit/repos?type=private", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": userAgent, + }, + }); +}); + +test("Pass route string as first argument without options", () => { + const options = endpoint("GET /"); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": userAgent, + }, + }); +}); + +test("Custom user-agent header", () => { + const options = endpoint("GET /", { + headers: { + // also test that header keys GET lowercased + "User-Agent": "my-app/1.2.3", + }, + }); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "my-app/1.2.3", + }, + }); +}); + +test("Full URL", () => { + const options = endpoint( + "GET https://codeload.github.com/octokit/endpoint-abcde/legacy.tar.gz/master" + ); + + assert.equal(options, { + method: "GET", + url: "https://codeload.github.com/octokit/endpoint-abcde/legacy.tar.gz/master", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": userAgent, + }, + }); +}); + +test("Should properly handle either placeholder format on url", () => { + const { url: url1 } = endpoint("GET /repos/{owner}/{repo}/contents/{path}", { + owner: "owner", + repo: "repo", + path: "path/to/file.txt", + }); + const { url: url2 } = endpoint("GET /repos/{owner}/{repo}/contents/{path}", { + owner: "owner", + repo: "repo", + path: "path/to/file.txt", + }); + assert.equal(url1, url2); +}); + +test("Request body", () => { + const options = endpoint("POST /repos/{owner}/{repo}/issues", { + owner: "octocat", + repo: "hello-world", + headers: { + accept: "text/html;charset=utf-8", + }, + title: "Found a bug", + body: "I'm having a problem with this.", + assignees: ["octocat"], + milestone: 1, + labels: ["bug"], + }); + + assert.equal(options, { + method: "POST", + url: "https://api.github.com/repos/octocat/hello-world/issues", + headers: { + accept: "text/html;charset=utf-8", + "content-type": "application/json; charset=utf-8", + "user-agent": userAgent, + }, + body: { + assignees: ["octocat"], + body: "I'm having a problem with this.", + labels: ["bug"], + milestone: 1, + title: "Found a bug", + }, + }); +}); + +test("Put without request body", () => { + const options = endpoint("PUT /user/starred/{owner}/{repo}", { + headers: { + authorization: `token 0000000000000000000000000000000000000001`, + }, + owner: "octocat", + repo: "hello-world", + }); + + assert.equal(options, { + method: "PUT", + url: "https://api.github.com/user/starred/octocat/hello-world", + headers: { + authorization: `token 0000000000000000000000000000000000000001`, + accept: "application/vnd.github.v3+json", + "content-length": 0, + "user-agent": userAgent, + }, + body: "", + }); +}); + +test("Query parameter template", () => { + const options = endpoint( + "POST https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets{?name,label}", + { + name: "example.zip", + label: "short description", + headers: { + "content-type": "text/plain", + "content-length": 14, + authorization: `token 0000000000000000000000000000000000000001`, + }, + data: "Hello, world!", + } + ); + + assert.equal(options, { + method: "POST", + url: "https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets?name=example.zip&label=short%20description", + headers: { + accept: "application/vnd.github.v3+json", + authorization: `token 0000000000000000000000000000000000000001`, + "content-type": "text/plain", + "content-length": 14, + "user-agent": userAgent, + }, + body: "Hello, world!", + }); +}); + +test("URL with query parameter and additional options", () => { + const options = endpoint("GET /orgs/octokit/repos?access_token=abc4567", { + type: "private", + }); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/orgs/octokit/repos?access_token=abc4567&type=private", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": userAgent, + }, + }); +}); + +test("Set request body directly", () => { + const options = endpoint("POST /markdown/raw", { + data: "Hello world github/linguist#1 **cool**, and #1!", + headers: { + accept: "text/html;charset=utf-8", + "content-type": "text/plain", + }, + }); + + assert.equal(options, { + method: "POST", + url: "https://api.github.com/markdown/raw", + headers: { + accept: "text/html;charset=utf-8", + "content-type": "text/plain", + "user-agent": userAgent, + }, + body: "Hello world github/linguist#1 **cool**, and #1!", + }); +}); + +test("Encode q parameter", () => { + const options = endpoint("GET /search/issues", { + q: "location:Jyväskylä", + }); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/search/issues?q=location%3AJyv%C3%A4skyl%C3%A4", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": userAgent, + }, + }); +}); + +test("request parameter", () => { + const options = endpoint("GET /", { + request: { + timeout: 100, + }, + }); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": userAgent, + }, + request: { + timeout: 100, + }, + }); +}); + +test("request.agent", () => { + const options = endpoint("GET /", { + request: { + agent: new Agent(), + }, + }); + + assert.instance(options.request.agent, Agent); +}); + +test("Just URL", () => { + assert.equal(endpoint("/").url, "https://api.github.com/"); + assert.equal(endpoint("/").method, "GET"); + assert.equal( + endpoint("https://github.acme-inc/api/v3/").url, + "https://github.acme-inc/api/v3/" + ); +}); + +test("options.mediaType.format", () => { + const options = endpoint({ + method: "GET", + url: "/repos/{owner}/{repo}/issues/{number}", + mediaType: { + format: "raw", + }, + owner: "octokit", + repo: "endpoint.js", + number: 123, + }); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/repos/octokit/endpoint.js/issues/123", + headers: { + accept: "application/vnd.github.v3.raw", + "user-agent": userAgent, + }, + }); +}); + +test("options.mediaType.previews", () => { + const options = endpoint({ + method: "GET", + url: "/repos/{owner}/{repo}/issues/{number}", + mediaType: { + previews: ["symmetra"], + }, + owner: "octokit", + repo: "endpoint.js", + number: 123, + }); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/repos/octokit/endpoint.js/issues/123", + headers: { + accept: "application/vnd.github.symmetra-preview+json", + "user-agent": userAgent, + }, + }); +}); + +test("options.mediaType.previews with -preview suffix", () => { + const options = endpoint({ + method: "GET", + url: "/repos/{owner}/{repo}/issues/{number}", + mediaType: { + previews: ["jean-grey-preview", "symmetra-preview"], + }, + owner: "octokit", + repo: "endpoint.js", + number: 123, + }); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/repos/octokit/endpoint.js/issues/123", + headers: { + accept: + "application/vnd.github.jean-grey-preview+json,application/vnd.github.symmetra-preview+json", + "user-agent": userAgent, + }, + }); +}); + +test("options.mediaType.format + options.mediaType.previews", () => { + const options = endpoint({ + method: "GET", + url: "/repos/{owner}/{repo}/issues/{number}", + mediaType: { + format: "raw", + previews: ["symmetra"], + }, + owner: "octokit", + repo: "endpoint.js", + number: 123, + }); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/repos/octokit/endpoint.js/issues/123", + headers: { + accept: "application/vnd.github.symmetra-preview.raw", + "user-agent": userAgent, + }, + }); +}); + +test("options.mediaType.format + options.mediaType.previews + accept header", () => { + const options = endpoint({ + method: "GET", + url: "/repos/{owner}/{repo}/issues/{number}", + headers: { + accept: "application/vnd.foo-preview,application/vnd.bar-preview", + }, + mediaType: { + format: "raw", + previews: ["symmetra"], + }, + owner: "octokit", + repo: "endpoint.js", + number: 123, + }); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/repos/octokit/endpoint.js/issues/123", + headers: { + accept: + "application/vnd.github.foo-preview.raw,application/vnd.github.bar-preview.raw,application/vnd.github.symmetra-preview.raw", + "user-agent": userAgent, + }, + }); +}); + +test("application/octet-stream accept header + previews", () => { + const options = endpoint({ + method: "GET", + url: "/repos/{owner}/{repo}/releases/assets/{asset_id}", + headers: { + accept: "application/octet-stream", + }, + mediaType: { + previews: ["symmetra"], + }, + owner: "octokit", + repo: "endpoint.js", + asset_id: 123, + }); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/repos/octokit/endpoint.js/releases/assets/123", + headers: { + accept: "application/octet-stream", + "user-agent": userAgent, + }, + }); +}); + +test("Undefined query parameter", () => { + const options = endpoint({ + method: "GET", + url: "/notifications", + before: undefined, + }); + + assert.equal(options, { + method: "GET", + url: "https://api.github.com/notifications", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": userAgent, + }, + }); +}); + +test("Undefined header value", () => { + const options = endpoint({ + method: "GET", + url: "/notifications", + headers: { + "if-modified-since": undefined, + }, + }); + + assert.not("if-modified-since" in options.headers); +}); + +test.run(); diff --git a/packages/endpoint/test/merge.test.js b/packages/endpoint/test/merge.test.js new file mode 100644 index 0000000..171501e --- /dev/null +++ b/packages/endpoint/test/merge.test.js @@ -0,0 +1,131 @@ +import { suite } from "uvu"; +import * as assert from "uvu/assert"; +import { getUserAgent } from "universal-user-agent"; + +import { endpoint } from "../index.js"; +import { VERSION } from "../lib/version.js"; +const userAgent = `octokit-endpoint.js/${VERSION} ${getUserAgent()}`; + +const test = suite("endpoint.merge()"); + +test("is a function", () => { + assert.instance(endpoint.merge, Function, "endpoint.merge is a function"); +}); + +test("README example", () => { + const myProjectEndpoint = endpoint.defaults({ + baseUrl: "https://github-enterprise.acme-inc.com/api/v3", + headers: { + "user-agent": "myApp/1.2.3", + }, + org: "my-project", + }); + const options = myProjectEndpoint.merge("GET /orgs/{org}/repos", { + headers: { + authorization: `token 0000000000000000000000000000000000000001`, + }, + type: "private", + }); + + assert.equal(options, { + baseUrl: "https://github-enterprise.acme-inc.com/api/v3", + mediaType: { + format: "", + previews: [], + }, + method: "GET", + url: "/orgs/{org}/repos", + headers: { + accept: "application/vnd.github.v3+json", + authorization: `token 0000000000000000000000000000000000000001`, + "user-agent": "myApp/1.2.3", + }, + org: "my-project", + type: "private", + }); +}); + +test("repeated defaults", () => { + const myProjectEndpoint = endpoint.defaults({ + baseUrl: "https://github-enterprise.acme-inc.com/api/v3", + headers: { + "user-agent": "myApp/1.2.3", + }, + org: "my-project", + }); + const myProjectEndpointWithAuth = myProjectEndpoint.defaults({ + headers: { + authorization: `token 0000000000000000000000000000000000000001`, + }, + }); + + const options = myProjectEndpointWithAuth.merge(`GET /orgs/{org}/repos`); + + assert.equal(options, { + baseUrl: "https://github-enterprise.acme-inc.com/api/v3", + mediaType: { + format: "", + previews: [], + }, + method: "GET", + url: "/orgs/{org}/repos", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "myApp/1.2.3", + authorization: `token 0000000000000000000000000000000000000001`, + }, + org: "my-project", + }); +}); + +test("no arguments", () => { + const options = endpoint.merge(); + assert.equal(options, { + baseUrl: "https://api.github.com", + mediaType: { + format: "", + previews: [], + }, + method: "GET", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": userAgent, + }, + }); +}); + +test("does not mutate the route param", () => { + const route = { + owner: "octokit", + repo: "endpoint.js", + }; + + endpoint.merge(route); + + assert.equal(route, { + owner: "octokit", + repo: "endpoint.js", + }); +}); + +test("does not mutate/lowercase the headers field of route", () => { + const route = { + owner: "octokit", + repo: "endpoint.js", + headers: { + "Content-Type": "application/json", + }, + }; + + endpoint.merge(route); + + assert.equal(route, { + owner: "octokit", + repo: "endpoint.js", + headers: { + "Content-Type": "application/json", + }, + }); +}); + +test.run(); diff --git a/packages/endpoint/test/parse.test.js b/packages/endpoint/test/parse.test.js new file mode 100644 index 0000000..fb5a95b --- /dev/null +++ b/packages/endpoint/test/parse.test.js @@ -0,0 +1,59 @@ +import { suite } from "uvu"; +import * as assert from "uvu/assert"; + +import { endpoint } from "../index.js"; + +const test = suite("endpoint.parse()"); + +test("is a function", () => { + assert.instance(endpoint.parse, Function, "endpoint.parse is a function"); +}); + +test("README example", () => { + const input = { + method: "GET", + url: "/orgs/{org}/repos", + org: "octokit", + type: "private", + }; + + assert.equal(endpoint(input), endpoint.parse(endpoint.merge(input))); +}); + +test("defaults url to ''", () => { + const { url } = endpoint.parse({ + method: "GET", + baseUrl: "https://example.com", + headers: { + accept: "foo", + "user-agent": "bar", + }, + mediaType: { + format: "", + previews: [], + }, + }); + assert.equal(url, "https://example.com/"); +}); + +test("does not alter input options", () => { + const input = { + baseUrl: "https://api.github.com/v3", + method: "GET", + url: "/", + headers: { + accept: "application/vnd.github.v3+json", + "user-agent": "myApp v1.2.3", + }, + mediaType: { + format: "", + previews: ["foo", "bar"], + }, + }; + + endpoint.parse(input); + + assert.equal(input.headers.accept, "application/vnd.github.v3+json"); +}); + +test.run(); diff --git a/packages/types-rest-api-ghes-3.0/index.d.ts b/packages/types-rest-api-ghes-3.0/index.d.ts index c1627de..529c3ab 100644 --- a/packages/types-rest-api-ghes-3.0/index.d.ts +++ b/packages/types-rest-api-ghes-3.0/index.d.ts @@ -32,6 +32,8 @@ declare module "@octokit-next/types" { namespace Octokit { interface ApiVersions { "ghes-3.0": { + RequestHeaders: Octokit.ApiVersions["github.com"]["RequestHeaders"]; + ResponseHeaders: Simplify< Octokit.ApiVersions["ghes-3.1"]["ResponseHeaders"] & ResponseHeadersDiff diff --git a/packages/types-rest-api-ghes-3.1/index.d.ts b/packages/types-rest-api-ghes-3.1/index.d.ts index b8a0d8a..4589932 100644 --- a/packages/types-rest-api-ghes-3.1/index.d.ts +++ b/packages/types-rest-api-ghes-3.1/index.d.ts @@ -46,6 +46,8 @@ declare module "@octokit-next/types" { namespace Octokit { interface ApiVersions { "ghes-3.1": { + RequestHeaders: Octokit.ApiVersions["github.com"]["RequestHeaders"]; + ResponseHeaders: Simplify< Octokit.ApiVersions["ghes-3.2"]["ResponseHeaders"] & ResponseHeadersDiff diff --git a/packages/types-rest-api-ghes-3.2/index.d.ts b/packages/types-rest-api-ghes-3.2/index.d.ts index d6a1fd0..15a5100 100644 --- a/packages/types-rest-api-ghes-3.2/index.d.ts +++ b/packages/types-rest-api-ghes-3.2/index.d.ts @@ -734,6 +734,8 @@ declare module "@octokit-next/types" { namespace Octokit { interface ApiVersions { "ghes-3.2": { + RequestHeaders: Octokit.ApiVersions["github.com"]["RequestHeaders"]; + ResponseHeaders: Simplify< Octokit.ApiVersions["github.com"]["ResponseHeaders"] & ResponseHeadersDiff diff --git a/packages/types-rest-api-github.ae/index.d.ts b/packages/types-rest-api-github.ae/index.d.ts index 61a6d2f..09e0a72 100644 --- a/packages/types-rest-api-github.ae/index.d.ts +++ b/packages/types-rest-api-github.ae/index.d.ts @@ -559,6 +559,8 @@ declare module "@octokit-next/types" { namespace Octokit { interface ApiVersions { "github.ae": { + RequestHeaders: Octokit.ApiVersions["github.com"]["RequestHeaders"]; + ResponseHeaders: Simplify< Octokit.ApiVersions["github.com"]["ResponseHeaders"] & ResponseHeadersDiff diff --git a/packages/types-rest-api/operation.d.ts b/packages/types-rest-api/operation.d.ts index e9b1b1c..acdfd7f 100644 --- a/packages/types-rest-api/operation.d.ts +++ b/packages/types-rest-api/operation.d.ts @@ -11,6 +11,8 @@ type KnownJsonResponseTypes = | "text/html" | "text/plain"; // GET /zen +type ReadOnlyMethods = "get" | "head"; + export type Operation< paths extends Record, Method extends keyof paths[Url], @@ -20,12 +22,20 @@ export type Operation< parameters: Simplify< ToOctokitParameters & RequiredPreview >; - request: { - method: Method extends string ? Uppercase : never; - url: Url; - headers: Octokit.RequestHeaders; - request: Octokit.RequestOptions; - }; + request: Method extends ReadOnlyMethods + ? { + method: Method extends string ? Uppercase : never; + url: Url; + headers: Octokit.RequestHeaders; + request: Octokit.RequestOptions; + } + : { + method: Method extends string ? Uppercase : never; + url: Url; + headers: Octokit.RequestHeaders; + request: Octokit.RequestOptions; + data: ExtractRequestBody; + }; response: ExtractOctokitResponse; }; diff --git a/packages/types/endpoint.d.ts b/packages/types/endpoint.d.ts new file mode 100644 index 0000000..005313d --- /dev/null +++ b/packages/types/endpoint.d.ts @@ -0,0 +1,232 @@ +import { SetRequired } from "type-fest"; + +import { Octokit } from "./index.js"; + +/** + * Generic request options as they are returned by the `endpoint()` method + */ +type GenericRequestOptions = { + method: Octokit.RequestMethod; + url: string; + headers: Octokit.RequestHeaders; + data?: unknown; + request?: Octokit.RequestOptions; +}; + +type EndpointParameters< + TVersion extends keyof Octokit.ApiVersions = "github.com" +> = { request?: Octokit.RequestOptions } & Record; + +/** + * The `EndpointInterface` is used for both the standalone `@octokit-next/endpoint` module + * as well as `@octokit-next/core`'s `.endpoint()` method. + * + * It has 3 overrides + * + * 1. When passing `{ request: { version }}` as part of the parameters, the passed version + * is used as a base for the types of the remaining parameters and the response + * 2. When a known route is passed, the types for the parameters and the response are + * derived from the version passed in `EndpointInterface`, which defaults to `"github.com"` + * 3. When no endpoint types are imported, then any route with any parameters can be passed in, and the response is unknown. + */ +export interface EndpointInterface< + TVersion extends keyof Octokit.ApiVersions = "github.com" +> { + /** + * Send a request to a known endpoint for the version specified in `request.version`. + * + * @param {string} route Request method + URL. Example: `'GET /orgs/{org}'` + * @param {object} parameters URL, query or body parameters, as well as `headers`, `mediaType.{format|previews}`, `request`, or `baseUrl`. + */ + ( + route: Route, + options: { + request: { + version: RVersion; + }; + } & (Route extends keyof ParametersByVersionAndRoute[RVersion] + ? ParametersByVersionAndRoute[RVersion][Route] + : never) + ): Route extends keyof RequestByVersionAndRoute[RVersion] + ? RequestByVersionAndRoute[RVersion][Route] + : never; + + /** + * Send a request to a known endpoint + * + * @param {string} route Request method + URL. Example: `'GET /orgs/{org}'` + * @param {object} parameters URL, query or body parameters, as well as `headers`, `mediaType.{format|previews}`, `request`, or `baseUrl`. + */ + ( + ...options: ArgumentsTypesForRoute< + Route, + // if given route is supported by current version + TVersion extends keyof ParametersByVersionAndRoute + ? // then set parameter types based on version and route + Route extends keyof ParametersByVersionAndRoute[TVersion] + ? ParametersByVersionAndRoute[TVersion][Route] + : // otherwise set parameter types to { request: { version } } where + // version must be set to one of the supported versions for the route. + // Once that happened, the above override will take over and require + // the types for the remaining options. + { + request: { + version: VersionsByRoute[Route]; + }; + } + : never + > + ): Route extends keyof RequestByVersionAndRoute[TVersion] + ? RequestByVersionAndRoute[TVersion][Route] + : never; + + /** + * It looks like you haven't imported any `@octokit-next/types-rest-api*` packages yet. + * You are missing out! + * + * Install `@octokit-next/types-rest-api` and import the types to give it a try. + * Learn more at https://github.com/octokit/types-rest-api.ts + * + * @param {string} route Request method + URL. Example: `'GET /orgs/{org}'` + * @param {object} [parameters] URL, query or body parameters, as well as `headers`, `mediaType.{format|previews}`, `request`, or `baseUrl`. + */ + ( + ...options: keyof Octokit.Endpoints extends never + ? [Route, Record?] + : [] + ): GenericRequestOptions; +} + +/** + * optimized type to lookup all known routes and the versions they are supported in. + * + * @example + * + * ```ts + * // import REST API types for github.com, GHES 3.2 and GHES 3.1 + * import "@octokit-next/types-rest-api-ghes-3.1"; + * ``` + * + * The `Octokit.ApiVersions` interface is now looking like this (simplified) + * + * ```ts + * { + * "github.com": { "GET /": { … } }, + * "ghes-3.1": { "GET /": { … }, "GET /admin/tokens": { … } }, + * "ghes-3.2": { "GET /": { … }, "GET /admin/tokens": { … } } + * } + * ``` + * + * The `VersionsByRoute` as a result looks like this + * + * ```ts + * { + * "GET /": "github.com" | "ghes-3.1" | "ghes-3.2", + * "GET /admin/tokens": "ghes-3.1" | "ghes-3.2" + * } + * ``` + */ +type VersionsByRoute = Remap; + +// types to improve performance of type lookups + +/** + * All known routes across all defined API versions for fast lookup + */ +type AllKnownRoutes = keyof VersionsByRoute; + +/** + * turn + * + * ```ts + * { [version]: { Endpoints: { [route]: Endpoint } } } + * ``` + * + * into + * + * ```ts + * { [version]: { [route]: Endpoint } } + * ``` + */ +type EndpointsByVersion = { + [Version in keyof Octokit.ApiVersions]: "Endpoints" extends keyof Octokit.ApiVersions[Version] + ? Octokit.ApiVersions[Version]["Endpoints"] + : never; +}; + +/** + * turn + * + * ```ts + * { [version]: { [route]: { parameters: Parameters } } } + * ``` + * + * into + * + * ```ts + * { [version]: { [route]: Parameters } } + * ``` + */ +type ParametersByVersionAndRoute = { + [Version in keyof EndpointsByVersion]: { + [Route in keyof EndpointsByVersion[Version]]: "parameters" extends keyof EndpointsByVersion[Version][Route] + ? EndpointsByVersion[Version][Route]["parameters"] & { + headers?: Octokit.RequestHeaders; + } + : never; + }; +}; + +/** + * turn + * + * ```ts + * { [version]: { [route]: { request: Request } } } + * ``` + * + * into + * + * ```ts + * { [version]: { [route]: Request } } + * ``` + */ +type RequestByVersionAndRoute = { + [Version in keyof EndpointsByVersion]: { + [Route in keyof EndpointsByVersion[Version]]: "request" extends keyof EndpointsByVersion[Version][Route] + ? EndpointsByVersion[Version][Route]["request"] & { + headers: SetRequired; + } + : never; + }; +}; + +// helpers + +/** + * Generic type to remap + * + * ```ts + * { k1: { k2: v }} + * ``` + * + * ```ts + * { k2: k1[]} + * ``` + */ +type Remap = { + [P in keyof T as keyof T[P]]: P; +}; + +/** + * Generic to find out if an object type has any required keys + */ +type NonOptionalKeys = { + [K in keyof Obj]: {} extends Pick ? undefined : K; +}[keyof Obj]; + +type ArgumentsTypesForRoute< + Route extends string, + Parameters extends Record +> = NonOptionalKeys extends undefined + ? [Route, Parameters?] + : [Route, Parameters]; diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 92c8ef5..bf13bf6 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -1,3 +1,5 @@ +import { EndpointInterface } from "./endpoint"; +export { EndpointInterface } from "./endpoint"; import { RequestInterface } from "./request"; export { RequestInterface } from "./request"; @@ -60,6 +62,8 @@ export namespace Octokit { request?: RequestOptions; } + type RequestMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; + interface RequestOptions< TVersion extends keyof Octokit.ApiVersions = "github.com" > { @@ -98,14 +102,15 @@ export namespace Octokit { * Avoid setting `headers.accept`, use `mediaType.{format|previews}` option instead. */ accept?: string; - /** - * Use `authorization` to send authenticated request, remember `token ` / `bearer ` prefixes. Example: `token 1234567890abcdef1234567890abcdef12345678` - */ - authorization?: string; /** * `user-agent` is set do a default and can be overwritten as needed. */ "user-agent"?: string; + /** + * Use `authorization` to send authenticated request, remember `token ` / `bearer ` prefixes. Example: `token 1234567890abcdef1234567890abcdef12345678` + */ + authorization?: string; + [header: string]: string | number | undefined; } @@ -227,6 +232,7 @@ export namespace Octokit { interface ApiVersions { "github.com": { ResponseHeaders: Octokit.ResponseHeaders; + RequestHeaders: Octokit.RequestHeaders; Endpoints: Octokit.Endpoints; }; } @@ -356,6 +362,7 @@ export declare class Octokit< ); request: RequestInterface; + endpoint: EndpointInterface; } /** diff --git a/scripts/types-rest-api-diff/templates/diff-to-ghes.d.ts.template b/scripts/types-rest-api-diff/templates/diff-to-ghes.d.ts.template index 6f7104e..56d91da 100644 --- a/scripts/types-rest-api-diff/templates/diff-to-ghes.d.ts.template +++ b/scripts/types-rest-api-diff/templates/diff-to-ghes.d.ts.template @@ -46,6 +46,8 @@ declare module "@octokit-next/types" { namespace Octokit { interface ApiVersions { "{{currentVersion}}": { + RequestHeaders: Octokit.ApiVersions["github.com"]["RequestHeaders"]; + ResponseHeaders: Simplify< Octokit.ApiVersions["{{diffVersion}}"]["ResponseHeaders"] & ResponseHeadersDiff diff --git a/scripts/types-rest-api-diff/templates/diff-to-github.com.d.ts.template b/scripts/types-rest-api-diff/templates/diff-to-github.com.d.ts.template index 246a5b1..9530e8e 100644 --- a/scripts/types-rest-api-diff/templates/diff-to-github.com.d.ts.template +++ b/scripts/types-rest-api-diff/templates/diff-to-github.com.d.ts.template @@ -41,6 +41,8 @@ declare module "@octokit-next/types" { namespace Octokit { interface ApiVersions { "{{currentVersion}}": { + RequestHeaders: Octokit.ApiVersions["github.com"]["RequestHeaders"]; + ResponseHeaders: Simplify< Octokit.ApiVersions["github.com"]["ResponseHeaders"] & ResponseHeadersDiff