3.0.0 • Published 3 months ago

make-service v3.0.0

Weekly downloads
-
License
MIT
Repository
github
Last release
3 months ago

NPM Library size All Contributors

make-service

A type-safe thin wrapper around the fetch API to better interact with external APIs.

It adds a set of little features and allows you to parse responses with zod.

Features

  • 🤩 Type-safe return of response.json() and response.text(). Defaults to unknown instead of any.
  • 🚦 Easily setup an API with a baseURL and common options like headers for every request.
  • 🏗️ Compose URL from the base by just calling the endpoints and an object-like query.
  • 🐾 Replaces URL wildcards with a strongly-typed object of params.
  • 🧙‍♀️ Automatically stringifies the body of a request so you can give it a JSON-like structure.
  • 🐛 Accepts a trace function for debugging.
  • 🔥 It can transform responses and payloads back and forth to (e.g.) support interchangeability of casing styles (kebab-case -> camelCase -> snake_case -> kebab-case).

Example

const service = makeService("https://example.com/api", {
  headers: {
    Authorization: "Bearer 123",
  },
});

const response = await service.get("/users")
const users = await response.json(usersSchema);
//    ^? User[]

Table of Contents

Installation

npm install make-service

Or you can use it with Deno:

import { makeService } from "https://deno.land/x/make_service/mod.ts";

API

This library exports the makeService function and some primitives used to build it. You can use the primitives as you wish but the makeService will have all the features combined.

makeService

The main function of this lib is built on top of the primitives described in the following sections. It allows you to create a service object with a baseURL and common options like headers for every request.

This service object can be called with every HTTP method and it will return a typedResponse.

import { makeService } from 'make-service'

const service = makeService("https://example.com/api", {
  headers :{
    authorization: "Bearer 123",
  },
})

const response = await service.get("/users")
const json = await response.json()
//    ^? unknown

On the example above, the request will be sent with the following arguments:

// "https://example.com/api/users"
// {
//   method: 'GET',
//   headers: {
//    'authorization': 'Bearer 123',
//   }
// }

Type-checking the response body

The response object returned by the service can be type-casted with a given generic type. This will type-check the response.json() and response.text() methods.

const response = await service.get("/users")
const users = await response.json<{ data: User[] }>()
//    ^? { data: User[] }
const content = await response.text<`${string}@${string}`>()
//    ^? `${string}@${string}`

Runtime type-checking and parsing the response body

Its typedResponse can also be parsed with a zod schema. Here follows a little more complex example:

const response = await service.get("/users")
const json = await response.json(
  z.object({
    data: z.object({
      users: z.array(z.object({
        name: z.string()
      }))
    })
  })
  // transformed and caught
  .transform(({ data: { users } }) => users)
  .catch([])
)
// type of json will be { name: string }[]

const content = await response.text(z.string().email())
// It will throw an error if the response.text is not a valid email

You can transform any Response in a TypedResponse like that by using the typedResponse function.

Supported HTTP Verbs

Other than the get it also accepts more HTTP verbs:

await service.get("/users")
await service.post("/users", { body: { name: "John" } })
await service.put("/users/1", { body: { name: "John" } })
await service.patch("/users/1", { body: { name: "John" } })
await service.delete("/users/1")
await service.head("/users")
await service.options("/users")

Headers

The headers argument can be a Headers object, a Record<string, string>, or an array of [key, value] tuples (entries). The headers option on baseOptions and the headers argument will be merged together, with the headers argument taking precedence.

import { makeService } from 'make-service'

const service = makeService("https://example.com/api", {
  headers: new Headers({
    authorization: "Bearer 123",
    accept: "*/*",
  }),
})

const response = await service.get("/users", {
  headers: [['accept', 'application/json']],
})

// It will call "https://example.com/api/users"
// with headers: { authorization: "Bearer 123", accept: "application/json" }

Passing a function as headers

The headers option on baseOptions can be a sync or async function that will run in every request before it gets merged with the other headers. This is particularly useful when you need to send a refreshed token or add a timestamp to the request.

import { makeService } from 'make-service'

declare getAuthorizationToken: () => Promise<HeadersInit>

const service = makeService("https://example.com/api", {
  headers: async () => ({
    authorization: await getAuthorizationToken(),
  }),
})

Deleting a previously set header

In case you want to delete a header previously set you can pass undefined or 'undefined' as its value:

const service = makeService("https://example.com/api", {
  headers: { authorization: "Bearer 123" },
})

const response = await service.get("/users", {
  headers: new Headers({ authorization: 'undefined' }),
})
// headers will be empty.

Note: Don't forget headers are case insensitive.

const headers = new Headers({ 'Content-Type': 'application/json' })
Object.fromEntries(headers) // equals to: { 'content-type': 'application/json' }

All the features above are done by using the mergeHeaders function internally.

Base URL

The service function can receive a string or URL as base url and it will be able to merge them correctly with the given path:

import { makeService } from 'make-service'

const service = makeService(new URL("https://example.com/api"))

const response = await service.get("/users?admin=true")

// It will call "https://example.com/api/users?admin=true"

You can use the makeGetApiUrl method to do that kind of URL composition.

Transformers

makeService can also receive requestTransformer and responseTransformer as options that will be applied to all requests.

Request transformers

You can transform the request in any way you want, like:

const service = makeService('https://example.com/api', {
  requestTransformer: (request) => ({ ...request, query: { admin: 'true' } }),
})

const response = await service.get("/users")

// It will call "https://example.com/api/users?admin=true"

Please note that the headers option will be applied after the request transformer runs. If you're using a request transformer, we recommend adding custom headers inside your transformer instead of using both options.

Response transformers

You can also transform the response in any way you want, like:

const service = makeService('https://example.com/api', {
  responseTransformer: (response) => ({ ...response, statusText: 'It worked!' }),
})

const response = await service.get("/users")

// response.statusText will be 'It worked!'

Body

The function can also receive a body object that will be stringified and sent as the request body:

import { makeService } from 'make-service'

const service = makeService("https://example.com/api")
const response = await service.post("/users", {
  body: { person: { firstName: "John", lastName: "Doe" } },
})

// It will make a POST request to "https://example.com/api/users"
// with stringified body: "{\"person\":{\"firstName\":\"John\",\"lastName\":\"Doe\"}}"

You can also pass any other accepted BodyInit values as body, such as FormData, URLSearchParams, Blob, ReadableStream, ArrayBuffer, etc.

import { makeService } from 'make-service'

const service = makeService("https://example.com/api")
const formData = new FormData([["name", "John"], ["lastName", "Doe"]])
const response = await service.post("/users", {
  body: formData,
})

This is achieved by using the ensureStringBody function internally.

Query

The service can also receive an query object that can be a string, a URLSearchParams, or an array of entries and it'll add that to the path as queryString:

import { makeService } from 'make-service'

const service = makeService(new URL("https://example.com/api"))

const response = await service.get("/users?admin=true", {
  query: new URLSearchParams({ page: "2" }),
})

// It will call "https://example.com/api/users?admin=true&page=2"

// It could also be:
const response = await service.get("/users?admin=true", {
  query: [["page", "2"]],
})
// or:
const response = await service.get("/users?admin=true", {
  query: "page=2",
})

This is achieved by using the addQueryToURL function internally.

Params

The function can also receive a params object that will be used to replace the :param wildcards in the path:

import { makeService } from 'make-service'

const service = makeService(new URL("https://example.com/api"))
const response = await service.get("/users/:id/article/:articleId", {
  params: { id: "2", articleId: "3" },
})

// It will call "https://example.com/api/users/2/article/3"

The params object will not type-check if the given object doesn't follow the path structure.

// @ts-expect-error
service.get("/users/:id", { params: { id: "2", foobar: "foo" } })

This is achieved by using the replaceURLParams function internally.

Trace

The function can also receive a trace function that will be called with the final url and requestInit arguments. Therefore you can know what are the actual arguments that will be passed to the fetch API.

import { makeService } from 'make-service'

const service = makeService("https://example.com/api")
const response = await service.get("/users/:id", {
  params: { id: "2" },
  query: { page: "2"},
  headers: { Accept: "application/json", "Content-type": "application/json" },
  trace: (url, requestInit) => {
    console.log("The request was sent to " + url)
    console.log("with the following params: " + JSON.stringify(requestInit))
  },
})

// It will log:
// "The request was sent to https://example.com/api/users/2?page=2"
// with the following params: { headers: { "Accept": "application/json", "Content-type": "application/json" } }

makeFetcher

This method is the same as makeService but it doesn't expose the HTTP methods as properties of the returned object. This is good for when you want to have a service setup but don't know the methods you'll be calling in advance, like in a proxy.

import { makeFetcher } from 'make-service'

const fetcher = makeFetcher("https://example.com/api")
const response = await fetcher("/users", { method: "POST", body: { email: "john@doe.com" } })
const json = await response.json()
//    ^? unknown

Other than having to pass the method in the RequestInit this is going to have all the features of makeService.

enhancedFetch

A wrapper around the fetch service. It returns a TypedResponse instead of a Response.

import { enhancedFetch } from 'make-service'

const response = await enhancedFetch("https://example.com/api/users", {
  method: 'POST',
  body: { some: { object: { as: { body } } } }
})
const json = await response.json()
//    ^? unknown
// You can pass it a generic or schema to type the result

This function accepts the same arguments as the fetch API - with exception of JSON-like body -, and it also accepts an object of params to replace URL wildcards, an object-like query, and a trace function. Those are all described above in makeService.

This slightly different RequestInit is typed as EnhancedRequestInit.

import { enhancedFetch } from 'make-service'

await enhancedFetch("https://example.com/api/users/:role", {
  method: 'POST',
  body: { some: { object: { as: { body } } } },
  query: { page: "1" },
  params: { role: "admin" },
  trace: console.log,
})

// The trace function will be called with the following arguments:
// "https://example.com/api/users/admin?page=1"
// {
//   method: 'POST',
//   body: '{"some":{"object":{"as":{"body":{}}}}}',
// }

typedResponse

A type-safe wrapper around the Response object. It adds a json and text method that will parse the response with a given zod schema. If you don't provide a schema, it will return unknown instead of any, then you can also give it a generic to type cast the result.

import { typedResponse } from 'make-service'
import type { TypedResponse } from 'make-service'

// With JSON
const response: TypedResponse = typedResponse(new Response(JSON.stringify({ foo: "bar" })))
const json = await response.json()
//    ^? unknown
const json = await response.json<{ foo: string }>()
//    ^? { foo: string }
const json = await response.json(z.object({ foo: z.string() }))
//    ^? { foo: string }

// With text
const response: TypedResponse = typedResponse(new Response("foo"))
const text = await response.text()
//    ^? string
const text = await response.text<`foo${string}`>()
//    ^? `foo${string}`
const text = await response.text(z.string().email())
//    ^? string

Transform the payload

The combination of make-service and string-ts libraries makes it easy to work with APIs that follow a different convention for object key's casing, so you can transform the request body before sending it or the response body after returning from the server. The resulting type will be properly typed 🤩.

import { makeService } from 'make-service'
import { deepCamelKeys, deepKebabKeys } from 'string-ts'

const service = makeService("https://example.com/api")
const response = service.get("/users")
const users = await response.json(
  z
    .array(z.object({ "first-name": z.string(), contact: z.object({ "home-address": z.string() }) }))
    .transform(deepCamelKeys)
)
console.log(users)
//          ^? { firstName: string, contact: { homeAddress: string } }[]

const body = deepKebabKeys({ firstName: "John", contact: { homeAddress: "123 Main St" } })
//    ^? { "first-name": string, contact: { "home-address": string } }
service.patch("/users/:id", { body, params: { id: "1" } })

Other available primitives

This little library has plenty of other useful functions that you can use to build your own services and interactions with external APIs.

addQueryToURL

It receives a URL instance or URL string and an object-like query and returns a new URL with the query appended to it.

It will preserve the original query if it exists and will also preserve the type of the given URL.

import { addQueryToURL } from 'make-service'

addQueryToURL("https://example.com/api/users", { page: "2" })
// https://example.com/api/users?page=2

addQueryToURL(
  "https://example.com/api/users?role=admin",
  { page: "2" },
)
// https://example.com/api/users?role=admin&page=2

addQueryToURL(
  new URL("https://example.com/api/users"),
  { page: "2" },
)
// https://example.com/api/users?page=2

addQueryToURL(
  new URL("https://example.com/api/users?role=admin"),
  { page: "2" },
)
// https://example.com/api/users?role=admin&page=2

ensureStringBody

It accepts any value considered a BodyInit (the type of the body in fetch, such as ReadableStream | XMLHttpRequestBodyInit | null) and also accepts a JSON-like structure such as a number, string, boolean, array or object.

In case it detects a JSON-like structure it will return a stringified version of that payload. Otherwise the type will be preserved.

import { ensureStringBody } from 'make-service'

ensureStringBody({ foo: "bar" })
// '{"foo":"bar"}'
ensureStringBody("foo")
// 'foo'
ensureStringBody(1)
// '1'
ensureStringBody(true)
// 'true'
ensureStringBody(null)
// null
ensureStringBody(new ReadableStream())
// ReadableStream

// and so on...

makeGetApiURL

It creates an URL builder for your API. It works similarly to makeFetcher but will return the URL instead of a response.

You create a getApiURL function by giving it a baseURL and then it accepts a path and an optional query that will be merged into the final URL.

import { makeGetApiURL } from 'make-service'

const getApiURL = makeGetApiURL("https://example.com/api")
const url = getApiURL("/users?admin=true", { query: { page: "2" } })

// "https://example.com/api/users?admin=true&page=2"

Notice the extra slashes are gonna be added or removed as needed.

makeGetApiURL("https://example.com/api/")("/users")
// "https://example.com/api/users"
makeGetApiURL("https://example.com/api")("users")
// "https://example.com/api/users"

mergeHeaders

It merges multiple HeadersInit objects into a single Headers instance. They can be of any type that is accepted by the Headers constructor, like a Headers instance, a plain object, or an array of entries.

import { mergeHeaders } from 'make-service'

const headers1 = new Headers({ "Content-Type": "application/json" })
const headers2 = { Accept: "application/json" }
const headers3 = [["accept", "*/*"]]

const merged = mergeHeaders(headers1, headers2, headers3)
//    ^? Headers({ "content-Type": "application/json", "accept": "*/*" })

It will delete previous headers if undefined or "undefined" is given:

import { mergeHeaders } from 'make-service'

const headers1 = new Headers({ "Content-Type": "application/json", Accept: "application/json" })
const headers2 = { accept: undefined }
const headers3 = [["content-type", "undefined"]]

const merged = mergeHeaders(headers1, headers2, headers3)
//    ^? Headers({})

replaceURLParams

This function replaces URL wildcards with the given params.

import { replaceURLParams } from 'make-service'

const url = replaceURLParams(
  "https://example.com/users/:id/posts/:postId",
  { id: "2", postId: "3" },
)
// It will return: "https://example.com/users/2/posts/3"

The params will be strongly-typed which means they will be validated against the URL structure and will not type-check if the given object does not match that structure.

Contributors

Acknowledgements

This library is part of a code I've been carrying around for a while through many projects.

  • Seasoned - for backing my work and allowing me testing it on big codebases when I started sketching this API.
  • croods by @danielweinmann - a react data-layer library from pre-ReactQuery/pre-SWR era - gave me ideas and experience dealing with APIs after spending a lot of time in that codebase.
  • zod by @colinhacks changed my mindset about how to deal with external data.
  • zod-fetch by @mattpocock for the inspiration, when I realized I had a similar solution that could be extracted and be available for everyone to use.

I really appreciate your feedback and contributions. If you have any questions, feel free to open an issue or contact me on Twitter.

3.0.0

3 months ago

2.1.2

6 months ago

2.1.1

7 months ago

2.1.0

8 months ago

2.0.0-next.0

11 months ago

2.0.0

9 months ago

1.1.0

1 year ago

1.1.0-next.0

1 year ago

1.0.0

1 year ago

1.0.0-next.2

1 year ago

1.0.0-next.1

1 year ago

1.0.0-next.0

1 year ago

0.2.0

1 year ago

0.1.0

1 year ago

0.0.3

1 year ago

0.0.2

1 year ago

0.0.1

1 year ago