Skip to content

PAPI: Notes🔗

Composing Builders🔗

Builder functions can be separated for better composability. If defining builders separately in TypeScript, do not explicitly type the return value from your function and let PAPI infer the resulting shape for you:

import { papi, type APIDefBuilder } from '@studiokeywi/papi';

const badPath = (path: APIDefBuilder): APIDefBuilder => path.get([0]);
const goodPath = (path: APIDefBuilder) => path.get([0]);
const apiCaller = papi('https://jsonplaceholder.typicode.com') //
  .path('bad', badPath)
  .path('good', goodPath)
  .build();
const bad /* (1)! */ = await apiCaller.bad.get();
const good /* (2)! */ = await apiCaller.good.get();
  1. Property 'get' does not exist on type PAPI<{}>
  2. number[]

Declarative Building🔗

Using the optional second argument for PAPI can be used to bypass the builder syntax, if preferred. The magic key symbols are exported alongside the HTTP method symbols for this purpose. The general pattern is that paths are named with strings, slugs are named with the $slug magic key, endpoints are named by their respective HTTP method's magic key, and defined the with $bodyOpt, $bodyReq, $error, $queryOpt, $queryReq, $response, $responseAltBody, and $responseAltQuery magic keys:

import { $GET, $error, $queryOpt, $response, $responseAltQuery, $slug, papi } from '@studiokeywi/papi';

const someShape = { id: 0, name: '' };
const rootObj = {
  foo /* (1)! */: {
    [$GET /* (2)! */]: {
      [$response /* (3)! */]: [0],
      [$queryOpt /* (4)! */]: { id: 0 },
      [$responseAltQuery /* (5)! */]: someShape,
    },
    [$slug /* (6)! */]: {
      [$GET]: {
        [$error]: { error: '' },
        [$response]: someShape,
      },
    },
  },
};

const apiCaller = papi('https://jsonplaceholder.typicode.com', rootObj).build();
const ids /* (7)! */ = await apiCaller.foo.get();
const data /* (8)! */ = await Promise.all(ids.map(id => apiCaller.foo.get({ query: { id } })));
const user5 = await apiCaller.foo[data[4].id].get();
if ('error' in user5) throw new Error(`Unable to retrieve user: ${user5.error}`);
user5 /* (9)! */;
  1. Paths are named with strings
  2. Endpoints are named with HTTP method magic keys
  3. Responses are defined with the $response key
  4. Additional information can be provided with $bodyOpt, $bodyReq, $queryOpt, and $queryReq
  5. Alternate response shapes can be defined when data or query params are passed with $responseAltBody and $responseAltQuery
  6. Slugs are named with the $slug magic key
  7. typeof ids = number[]
  8. typeof data = { id: number; name: string }[]
  9. typeof user5 = { id: number; name: string }

Response Priority🔗

When defining alternate responses for endpoints, the type PAPI will assume is based on the following priorities list:

  1. The response type associated with the value provided for parse when making an API call, OR
  2. The response shape defined with either .bodyOpt or .bodyReq when the API was called with a data value, OR
  3. The response shape defined with either .queryOpt or .queryReq when the API was called with a query value, OR
  4. The response shape defined with the endpoint function

Specificity of Responses🔗

The design of PAPI is flexible enough to allow you to pass custom shapes to a variety of builder functions in TypeScript. This is to allow more complex shapes to be defined while permitting the simplicity of generic object shapes in JavaScript. An example with optional response values from an endpoint is shown below:

import { papi } from '@studiokeywi/papi';

const fooOptional: { foo?: string } = {};
const response /* (1)! */ = await papi('https://jsonplaceholder.typicode.com')
  .path('dummy', path => path.get(fooOptional))
  .build()
  .dummy.get();
  1. typeof response = { foo?: string }

This is especially important to remember when defining API responses that are primitives -- If you say an endpoint will return a numeric/string value, PAPI will assume you mean the specific value that was provided. This can be bypassed by explicitly declaring the endpoint response type:

import { papi } from '@studiokeywi/papi';

const builder = papi('https://jsonplaceholder.typicode.com');

const specificValue /* (1)! */ = await builder
  .path('dummy', path => path.get('foo'))
  .build()
  .dummy.get();

const primitive /* (2)! */ = await builder
  .path('dummy', path => path.get<string>('foo'))
  .build()
  .dummy.get();
  1. typeof specificValue = 'foo'
  2. typeof primitive = string

Conversely, you can enforce specific values of the properties of an API response with as const. The entire shape can be marked, or individual properties (including on nested objects) can be marked as needed:

import { papi } from '@studiokeywi/papi';

const builder = papi('https://jsonplaceholder.typicode.com');

const guaranteedValues /* (1)! */ = await builder
  .path('dummy', path =>
    path.get({
      foo: [123, 456] as const,
      bar: 'abc',
      baz: { puz: true as const },
    })
  )
  .build()
  .dummy.get();

const exactShape /* (2)! */ = await builder
  .path('dummy', path =>
    path.get({
      foo: [123, 456],
      bar: 'abc',
      baz: { puz: true },
    } as const)
  )
  .build()
  .dummy.get();
  1. typeof guaranteedValues = { foo: readonly [123, 456], bar: string, baz: { puz: true } }
  2. typeof exactShape = { readonly foo: readonly [123, 456], readonly bar: 'abc', readonly baz: { readonly puz: true } }

TAPI🔗

Passing in a life value to the .build function creates a Temporary API Caller (TAPI). These are useful for situations where you may only want temporary access provided to an API. The TAPI will cease to function after the life duration has expired, enforced by a Proxy.revocable. The timer begins from TAPI creation and is refreshed everytime it is used to call any endpoint:

import { papi } from '@studiokeywi/papi';

const tapi = papi('https://jsonplaceholder.typicode.com')
  .path('dummy', path => path.get([0]))
  .build({ life: 300_000 /* (1)! */ });
const ids = await tapi.dummy.get();
// etc
  1. 300000 is 5 minutes -- 5 minutes * (60 seconds / minute) * (1000 milliseconds / second)

Type Narrowing🔗

When defining error shapes, the API responses can be narrowed through type guards or assertions within TypeScript. While not mandatory, they can make processing responses easier to deal with. An example invariant function is defined below, which can be used to throw errors based on input shapes:

function invariant(condition: any, msg?: string): asserts condition {
  if (!condition) throw new Error(msg ?? 'Invariant failed');
}

type SuccessResponse = { data: string } | { data: number };
type ErrorResponse = { error: { message: string } };
type APIResponse = SuccessResponse | ErrorResponse;

const resp = <APIResponse>{};
invariant(!('error' in resp));
resp /* (1)! */;
  1. typeof resp = SuccessResponse

TypeScript Serialization🔗

When using more advanced shape definitions in PAPI in your projects and depending on your TypeScript configuration, you may sometimes get a warning saying that certain symbols cannot be serialized. To get around this, instead of exporting or directly wrapping around PAPI caller functions, use an async function and await the PAPI caller to get the returned values instead:

import { papi } from '@studiokeywi/papi';

const api = papi('https://jsonplaceholder.typicode.com')
  .path('dummy', path => path.get([0], g => g.error({ err: '' })))
  .build();

export const dummy = api.dummy.get; /* (1)! */
export const callDummyBad = () => api.dummy.get(); /* (2)! */
export const callDummyGood = async () => await api.dummy.get();
  1. The type of this node cannot be serialized because its property [$error] cannot be serialized. ts(4118)
    The type of this node cannot be serialized because its property [$response] cannot be serialized. ts(4118)
  2. The type of this node cannot be serialized because its property [$error] cannot be serialized. ts(4118)
    The type of this node cannot be serialized because its property [$response] cannot be serialized. ts(4118)