Skip to content

Commit

Permalink
feat: first blood, should just work
Browse files Browse the repository at this point in the history
  • Loading branch information
JounQin committed Nov 7, 2023
1 parent 8684f37 commit acd70ae
Show file tree
Hide file tree
Showing 13 changed files with 375 additions and 112 deletions.
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"changelog": [
"@changesets/changelog-github",
{
"repo": "un-ts/lib-boilerplate"
"repo": "un-ts/fetch-api"
}
],
"commit": false,
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ jobs:
with:
publish: yarn release
version: yarn changeset version
commit: 'chore: release lib-boilerplate'
title: 'chore: release lib-boilerplate'
commit: 'chore: release fetch-api'
title: 'chore: release fetch-api'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
58 changes: 44 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
# lib-boilerplate
# fetch-api

[![GitHub Actions](https://github.com/un-ts/lib-boilerplate/workflows/CI/badge.svg)](https://github.com/un-ts/lib-boilerplate/actions/workflows/ci.yml)
[![Codecov](https://img.shields.io/codecov/c/github/un-ts/lib-boilerplate.svg)](https://codecov.io/gh/un-ts/lib-boilerplate)
[![type-coverage](https://img.shields.io/badge/dynamic/json.svg?label=type-coverage&prefix=%E2%89%A5&suffix=%&query=$.typeCoverage.atLeast&uri=https%3A%2F%2Fraw.githubusercontent.com%2Fun-ts%2Flib-boilerplate%2Fmain%2Fpackage.json)](https://github.com/plantain-00/type-coverage)
[![npm](https://img.shields.io/npm/v/lib-boilerplate.svg)](https://www.npmjs.com/package/lib-boilerplate)
[![GitHub Release](https://img.shields.io/github/release/un-ts/lib-boilerplate)](https://github.com/un-ts/lib-boilerplate/releases)
[![GitHub Actions](https://github.com/un-ts/fetch-api/workflows/CI/badge.svg)](https://github.com/un-ts/fetch-api/actions/workflows/ci.yml)
[![Codecov](https://img.shields.io/codecov/c/github/un-ts/fetch-api.svg)](https://codecov.io/gh/un-ts/fetch-api)
[![type-coverage](https://img.shields.io/badge/dynamic/json.svg?label=type-coverage&prefix=%E2%89%A5&suffix=%&query=$.typeCoverage.atLeast&uri=https%3A%2F%2Fraw.githubusercontent.com%2Fun-ts%2Ffetch-api%2Fmain%2Fpackage.json)](https://github.com/plantain-00/type-coverage)
[![npm](https://img.shields.io/npm/v/fetch-api.svg)](https://www.npmjs.com/package/fetch-api)
[![GitHub Release](https://img.shields.io/github/release/un-ts/fetch-api)](https://github.com/un-ts/fetch-api/releases)

[![Conventional Commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow.svg)](https://conventionalcommits.org)
[![Renovate enabled](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://renovatebot.com)
[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
[![Code Style: Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier)
[![changesets](https://img.shields.io/badge/maintained%20with-changesets-176de3.svg)](https://github.com/changesets/changesets)

A simple library boilerplate.
A simple but elegant `fetch` API wrapper, use `fetch` like a charm

## TOC <!-- omit in toc -->

Expand All @@ -30,21 +30,51 @@ A simple library boilerplate.

```sh
# pnpm
pnpm add lib-boilerplate
pnpm add fetch-api

# yarn
yarn add lib-boilerplate
yarn add fetch-api

# npm
npm i lib-boilerplate
npm i fetch-api
```

### API

```js
import echo from 'lib-boilerplate'

echo()
```ts
import { ApiMethod, createFetchApi, fetchApi, interceptors } from 'fetch-api'

// plain url, GET method
await fetchApi('url')

// with options, `body`, `query`, etc.
await fetchApi('url', {
method: ApiMethod.POST, // or 'POST'
// plain object or array, or BodyInit
body: {},
// URLSearchParametersOptions
query: {
key: 'value',
},
// json: boolean, // whether auto stringify body to json, default true for plain object or array, otherwise false
// type: 'arrayBuffer' | 'blob' | 'json' | 'text' | null, `null` means plain `Response`
})

const interceptor: ApiInterceptor = (req, next) => {
// do something with req
const res = await next(req)
// do something with res
return res
}

// add interceptor
interceptors.use(interceptor)

// remove interceptor
interceptors.eject(interceptor)

// create a new isolated `fetchApi` with its own `interceptors`
const { fetchApi, interceptors } = createFetchApi()
```

## Sponsors
Expand Down
2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>lib-boilerplate</title>
<title>fetch-api</title>
</head>
<body>
<div id="app"></div>
Expand Down
9 changes: 4 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"name": "lib-boilerplate",
"name": "fetch-api",
"version": "0.1.0",
"type": "module",
"description": "A simple library boilerplate.",
"repository": "git+https://github.com/un-ts/lib-boilerplate.git",
"description": "A simple but elegant `fetch` API wrapper, use `fetch` like a charm",
"repository": "git+https://github.com/un-ts/fetch-api.git",
"author": "JounQin (https://www.1stG.me) <admin@1stg.me>",
"funding": "https://opencollective.com/unts",
"license": "MIT",
Expand Down Expand Up @@ -49,9 +49,8 @@
"@types/node": "^20.8.10",
"@types/react": "^18.2.36",
"@types/react-dom": "^18.2.14",
"@types/web": "^0.0.119",
"@vitejs/plugin-react-swc": "^3.4.1",
"@vitest/coverage-v8": "^0.34.6",
"@vitest/coverage-istanbul": "^0.34.6",
"commitlint": "^18.2.0",
"eslint": "^8.53.0",
"github-markdown-css": "^5.4.0",
Expand Down
148 changes: 147 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,147 @@
export default () => 'Hello World!'
import type { URLSearchParametersOptions, ValueOf } from './types.js'
import { CONTENT_TYPE, isPlainObject, normalizeUrl } from './utils.js'

export type * from './types.js'
export * from './utils.js'

export const ApiMethod = {
GET: 'GET',
POST: 'POST',
PATCH: 'PATCH',
PUT: 'PUT',
DELETE: 'DELETE',
} as const

export type ApiMethod = ValueOf<typeof ApiMethod>

export interface FetchApiOptions extends Omit<RequestInit, 'body' | 'method'> {
method?: ApiMethod
body?: BodyInit | object
query?: URLSearchParametersOptions
json?: boolean
type?: 'arrayBuffer' | 'blob' | 'json' | 'text' | null
}

export interface InterceptorRequest extends FetchApiOptions {
url: string
}

export type ApiInterceptor = (
request: InterceptorRequest,
next: (request: InterceptorRequest) => PromiseLike<Response>,
) => PromiseLike<Response> | Response

export interface ResponseError<T = never> extends Error {
data?: T | null
response?: Response | null
}

export class ApiInterceptors {
readonly #interceptors: ApiInterceptor[] = []

get length() {
return this.#interceptors.length
}

at(index: number) {
return this.#interceptors.at(index)
}

use(...interceptors: ApiInterceptor[]) {
this.#interceptors.push(...interceptors)
return this
}

eject(interceptor: ApiInterceptor) {
const index = this.#interceptors.indexOf(interceptor)
if (index > -1) {
this.#interceptors.splice(index, 1)
return true
}
return false
}
}

export const createFetchApi = () => {
const interceptors = new ApiInterceptors()

function fetchApi(
url: string,
options: FetchApiOptions & { type: null },
): Promise<Response>
function fetchApi(
url: string,
options: FetchApiOptions & { type: 'arraybuffer' },
): Promise<ArrayBuffer>
function fetchApi(
url: string,
options: FetchApiOptions & { type: 'blob' },
): Promise<Blob>
function fetchApi(
url: string,
options: FetchApiOptions & { type: 'text' },
): Promise<string>
function fetchApi<T>(
url: string,
options?: FetchApiOptions & { type?: 'json' },
): Promise<T>
// eslint-disable-next-line sonarjs/cognitive-complexity
async function fetchApi(
url: string,
{
method = ApiMethod.GET,
body,
headers,
json = body != null && (isPlainObject(body) || Array.isArray(body)),
type = 'json',
...rest
}: FetchApiOptions = {},
) {
headers = new Headers(headers)

if (json && !headers.has(CONTENT_TYPE)) {
headers.append(CONTENT_TYPE, 'application/json')
}

let index = 0

const next = async (request: InterceptorRequest) => {
if (index < interceptors.length) {
return interceptors.at(index++)!(request, next)
}
const { body, url, query, ...rest } = request
const response = await fetch(normalizeUrl(url, query), {
...rest,
body: json ? JSON.stringify(body) : (body as BodyInit),
})
if (response.ok) {
return response
}
let data: unknown = null
if (type != null) {
try {
data = await response.clone()[type]()
} catch {
data = await response.clone().text()
}
}
throw Object.assign(new Error(response.statusText), {
data,
response,
})
}

const response = await next({
url,
method,
body,
headers,
...rest,
})
return type == null ? response : response.clone()[type]()
}

return { interceptors, fetchApi }
}

export const { interceptors, fetchApi } = createFetchApi()
13 changes: 13 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type Nullable<T> = T | null | undefined

export type ValueOf<T> = T[keyof T]

export type URLSearchParametersInit = ConstructorParameters<
typeof URLSearchParams
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
>[0]

export type URLSearchParametersOptions =
| Record<string, Nullable<number | string>>
| URLSearchParametersInit
| object
44 changes: 44 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
Nullable,
URLSearchParametersInit,
URLSearchParametersOptions,
ValueOf,
} from './types.js'

export const CONTENT_TYPE = 'Content-Type'

// eslint-disable-next-line @typescript-eslint/unbound-method
const { toString } = Object.prototype // type-coverage:ignore-line - TODO: report bug

const objectTag = '[object Object]'

export const isPlainObject = <T extends object>(value: unknown): value is T =>
toString.call(value) === objectTag

export const cleanNilValues = <T = unknown>(input: T, empty?: boolean): T => {
if (!isPlainObject(input)) {
return input
}

for (const _key of Object.keys(input)) {
const key = _key as keyof T
const value = input[key] as Nullable<ValueOf<T>>
if (empty ? !value : value == null) {
delete input[key]
} else {
input[key] = cleanNilValues(value, empty) as (T & object)[keyof T]
}
}

return input
}

export const normalizeUrl = (
url: string,
query?: URLSearchParametersOptions,
) => {
const search = new URLSearchParams(
cleanNilValues(query, true) as URLSearchParametersInit,
).toString()
return search ? url + (url.includes('?') ? '&' : '?') + search : url
}
Loading

0 comments on commit acd70ae

Please sign in to comment.