Skip to content

Commit

Permalink
Merge pull request #94 from makenotion/jake--isomorphic
Browse files Browse the repository at this point in the history
Rework error types, remove got in favor of node-fetch
  • Loading branch information
justjake authored Jun 21, 2021
2 parents 912fc52 + 3d4dfef commit caca095
Show file tree
Hide file tree
Showing 11 changed files with 381 additions and 211 deletions.
4 changes: 3 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ module.exports = {
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "none",
args: "all",
argsIgnorePattern: "^_",
// Allow assertion types.
varsIgnorePattern: "^_assert",
caughtErrors: "none",
ignoreRestSiblings: true,
},
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm run build --if-present
- run: npm run build
- run: npm run lint
- run: npm test
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,17 +143,24 @@ The `Client` supports the following options on initialization. These options are

This package contains type definitions for **all request parameters and responses**.

Error classes, such as `RequestTimeoutError` and `APIResponseError`, contain type guards as static methods which can be useful for narrowing the type of a thrown error. TypeScript infers all thrown errors to be `any` or `unknown`. These type guards will allow you to handle errors with better type support.
Because errors in TypeScript start with type `any` or `unknown`, you should use
the `isNotionClientError` type guard to handle them in a type-safe way. Each
`NotionClientError` type is uniquely identified by its `error.code`. Codes in
the `APIErrorCode` enum are returned from the server. Codes in the
`ClientErrorCode` enum are produced on the client.

```ts
try {
const response = notion.databases.query({
/* ... */
})
} catch (error: unknown) {
if (APIResponseError.isAPIResponseError(error)) {
// error is now strongly typed to APIResponseError
if (isNotionClientError(error)) {
// error is now strongly typed to NotionClientError
switch (error.code) {
case ClientErrorCode.RequestTimeout:
// ...
break
case APIErrorCode.ObjectNotFound:
// ...
break
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
],
"main": "./build/src",
"scripts": {
"prepare": "npm run lint && npm run build",
"prepare": "npm run build",
"prepublishOnly": "npm run lint && npm run test",
"build": "tsc",
"prettier": "prettier --write .",
"lint": "prettier --check . && eslint . --ext .ts && cspell '**/*' ",
Expand All @@ -33,10 +34,12 @@
"author": "",
"license": "MIT",
"files": [
"build/package.json",
"build/src/**"
],
"dependencies": {
"got": "^11.8.2"
"@types/node-fetch": "^2.5.10",
"node-fetch": "^2.6.1"
},
"devDependencies": {
"@ava/typescript": "^2.0.0",
Expand Down
160 changes: 80 additions & 80 deletions src/Client.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import type { Agent } from "http"
import { URL } from "url"
import {
Logger,
LogLevel,
logLevelSeverity,
makeConsoleLogger,
} from "./logging"
import { buildRequestError, HTTPResponseError } from "./errors"
import {
buildRequestError,
isHTTPResponseError,
isNotionClientError,
RequestTimeoutError,
} from "./errors"
import { pick } from "./helpers"
import {
BlocksChildrenAppendParameters,
Expand Down Expand Up @@ -43,22 +47,23 @@ import {
SearchResponse,
search,
} from "./api-endpoints"

import got, {
Got,
Options as GotOptions,
Headers as GotHeaders,
Agents as GotAgents,
} from "got"
import nodeFetch from "node-fetch"
import {
version as PACKAGE_VERSION,
name as PACKAGE_NAME,
} from "../package.json"
import { SupportedFetch } from "./fetch-types"

export interface ClientOptions {
auth?: string
timeoutMs?: number
baseUrl?: string
logLevel?: LogLevel
logger?: Logger
agent?: Agent
notionVersion?: string
fetch?: SupportedFetch
/** Silently ignored in the browser */
agent?: Agent
}

export interface RequestParameters {
Expand All @@ -73,30 +78,25 @@ export default class Client {
#auth?: string
#logLevel: LogLevel
#logger: Logger
#got: Got
#prefixUrl: string
#timeoutMs: number
#notionVersion: string
#fetch: SupportedFetch
#agent: Agent | undefined
#userAgent: string

static readonly defaultNotionVersion = "2021-05-13"

public constructor(options?: ClientOptions) {
this.#auth = options?.auth
this.#logLevel = options?.logLevel ?? LogLevel.WARN
this.#logger = options?.logger ?? makeConsoleLogger(this.constructor.name)

const prefixUrl = (options?.baseUrl ?? "https://api.notion.com") + "/v1/"
const timeout = options?.timeoutMs ?? 60_000
const notionVersion = options?.notionVersion ?? Client.defaultNotionVersion

this.#got = got.extend({
prefixUrl,
timeout,
headers: {
"Notion-Version": notionVersion,
// TODO: update with format appropriate for telemetry, use version from package.json
"user-agent": "notionhq-client/0.1.0",
},
retry: 0,
agent: makeAgentOption(prefixUrl, options?.agent),
})
this.#logger = options?.logger ?? makeConsoleLogger(PACKAGE_NAME)
this.#prefixUrl = (options?.baseUrl ?? "https://api.notion.com") + "/v1/"
this.#timeoutMs = options?.timeoutMs ?? 60_000
this.#notionVersion = options?.notionVersion ?? Client.defaultNotionVersion
this.#fetch = options?.fetch ?? nodeFetch
this.#agent = options?.agent
this.#userAgent = `notionhq-client/${PACKAGE_VERSION}`
}

/**
Expand All @@ -108,49 +108,77 @@ export default class Client {
* @param body
* @returns
*/
public async request<Response>({
public async request<ResponseBody>({
path,
method,
query,
body,
auth,
}: RequestParameters): Promise<Response> {
}: RequestParameters): Promise<ResponseBody> {
this.log(LogLevel.INFO, "request start", { method, path })

// If the body is empty, don't send the body in the HTTP request
const json =
body !== undefined && Object.entries(body).length === 0 ? undefined : body
const bodyAsJsonString =
!body || Object.entries(body).length === 0
? undefined
: JSON.stringify(body)

const url = new URL(`${this.#prefixUrl}${path}`)
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
url.searchParams.append(key, String(value))
}
}
}

const headers: Record<string, string> = {
...this.authAsHeaders(auth),
"Notion-Version": this.#notionVersion,
"user-agent": this.#userAgent,
}

if (bodyAsJsonString !== undefined) {
headers["content-type"] = "application/json"
}
try {
const response = await this.#got(path, {
method,
searchParams: query,
json,
headers: this.authAsHeaders(auth),
}).json<Response>()
const response = await RequestTimeoutError.rejectAfterTimeout(
this.#fetch(url.toString(), {
method,
headers,
body: bodyAsJsonString,
agent: this.#agent,
}),
this.#timeoutMs
)

const responseText = await response.text()
if (!response.ok) {
throw buildRequestError(response, responseText)
}

const responseJson: ResponseBody = JSON.parse(responseText)
this.log(LogLevel.INFO, `request success`, { method, path })
return response
} catch (error) {
// Build an error of a known type, otherwise throw unexpected errors
const requestError = buildRequestError(error)
if (requestError === undefined) {
return responseJson
} catch (error: unknown) {
if (!isNotionClientError(error)) {
throw error
}

// Log the error if it's one of our known error types
this.log(LogLevel.WARN, `request fail`, {
code: requestError.code,
message: requestError.message,
code: error.code,
message: error.message,
})
if (HTTPResponseError.isHTTPResponseError(requestError)) {

if (isHTTPResponseError(error)) {
// The response body may contain sensitive information so it is logged separately at the DEBUG level
this.log(LogLevel.DEBUG, `failed response body`, {
body: requestError.body,
body: error.body,
})
}

// Throw as a known error type
throw requestError
throw error
}
}

Expand Down Expand Up @@ -358,8 +386,8 @@ export default class Client {
* @param auth API key or access token
* @returns headers key-value object
*/
private authAsHeaders(auth?: string): GotHeaders {
const headers: GotHeaders = {}
private authAsHeaders(auth?: string): Record<string, string> {
const headers: Record<string, string> = {}
const authHeaderValue = auth ?? this.#auth
if (authHeaderValue !== undefined) {
headers["authorization"] = `Bearer ${authHeaderValue}`
Expand All @@ -372,34 +400,6 @@ export default class Client {
* Type aliases to support the generic request interface.
*/
type Method = "get" | "post" | "patch"
type QueryParams = GotOptions["searchParams"]
type QueryParams = Record<string, string | number> | URLSearchParams

type WithAuth<P> = P & { auth?: string }

/*
* Helper functions
*/

function makeAgentOption(
prefixUrl: string,
agent: Agent | undefined
): GotAgents | undefined {
if (agent === undefined) {
return undefined
}
return {
[selectProtocol(prefixUrl)]: agent,
}
}

function selectProtocol(prefixUrl: string): "http" | "https" {
const url = new URL(prefixUrl)

if (url.protocol === "https:") {
return "https"
} else if (url.protocol === "http:") {
return "http"
}

throw new TypeError(`baseUrl option must begin with "https://" or "http://"`)
}
Loading

0 comments on commit caca095

Please sign in to comment.