DEV Community

Cover image for Apity: A typed HTTP client for Svelte(Kit)
Denis
Denis

Posted on

Apity: A typed HTTP client for Svelte(Kit)

Most frontend projects need to make HTTP requests, but there's no convention how to handle them. We have a fetch function, but it would be inconvenient to call it directly each time, so often developers create their own API wrappers to call logical functions instead, for example:

const products = await API.fetchProducts({ userId })
// or
const response = await listProducts({ userId })
Enter fullscreen mode Exit fullscreen mode

I've seen a lot of code like this. Sometimes backend is implemented with the same technology as frontend and a framework provides helpers for network communication, for example SvelteKit has form actions. But in certain cases backend is a separate instance and you operate with its API, so such functions are unavoidable.

The main question with this approach is how to manage request parameters and data in responses. If you're a TypeScript user then you know the benefits of proper typing. TS warns you when you're trying to access wrong properties of objects and a lot of mistakes can be caught even before running the code. And of course having auto completion in your IDE makes development experience much better. So by returning a raw response.json() you lose a lot of benefits.

However, there's a couple of issues with this approach:

1) Writing the types by hand is tedious and error prone
2) Backend changes over time, it can be a new property added to a response, or a new parameter in request body - so you have to keep both codebases in sync
3) Sometimes the types are implemented by backend team already, so duplicating them on frontend sounds like a double work

Combining all these concerns together and trying to find a proper solution resulted in Apity - a Svelte/SvelteKit library for making typed HTTP requests. In this article I'll try to show its bright sides and how to use it.

Installation and configuration

A pre-requisite before taking Apity into use is having an OpenAPI spec of a server. It should be either stored as a local file, or accessible by an URL. If you're not familiar with OpenAPI - it's a JSON or YAML file that lists server routes, expected parameters and responses. A lot of backend technologies support schema generation, for example FastAPI has it out of the box.

In this post we'll work with Petstore schema, so the first steps will be to install the library and generate types from OpenAPI schema (assuming you already have a SvelteKit application).

npm install @cocreators-ee/apity

npx openapi-typescript https://petstore3.swagger.io/api/v3/openapi.json --output src/petstore.ts
Enter fullscreen mode Exit fullscreen mode

Now we have all the necessary information about the backend in src/petstore.ts - it contains all the routes and schemas for requests and responses. Using these objects, we can construct and API wrapper for the routes almost automatically:

import { Apity } from '@cocreators-ee/apity'
import type { paths } from 'src/petstore'

const apity = Apity.for<paths>()

apity.configure({
  baseUrl: 'https://petstore.swagger.io/v2',
})

export const findPetsByStatus = apity
  .path('/pet/findByStatus')
  .method('get')
  .create()
export const addPet = apity.path('/pet').method('post').create()
Enter fullscreen mode Exit fullscreen mode

Yes, you have to define each API route by hand, but this was an intentional decision. You will operate with these functions in your code, so it's up to you how to name them. Assigning the names automatically from the spec could result in worse naming, or you might want to separate several parts into different wrappers.

Don't panic however, now TS knows your backend structure, so defining the routes will be rather quick, and it will warn you if you for example try to create a GET method for a POST route:

Image description

Image description

Usage

Now when you have your desired routes defined, just import the functions and call them:

const request = findPetsByStatus({ status: 'available' })
const response = await request.result
Enter fullscreen mode Exit fullscreen mode

Response object has a typed data property that's available when request is finished successfully. This can be checked with ok property:

Image description

This is crucial to check if request was successful before accessing the data, because failed request is also a valid case. This semantic forces you to handle bad cases as well, for example showing an error toast to the user.

Request parameters are also automatically suggested and validated for correctness:

Image description

Now that you've got a first impression of the library, let's dive into its features and see how it fits into different scenarios.

Server Side Rendering (SSR)

You can use the library in SvelteKit's load functions, to request data on server:

import { findPetByStatus } from 'src/api.ts'

export async function load({ fetch }) {
  const request = findPetByStatus({ status: 'sold' }, fetch)
  const resp = await request.result
  if (resp.ok) {
    return { pets: resp.data, error: undefined }
  } else {
    return { pets: [], error: 'Failed to load pets' }
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that in this case you need to explicitly provide server's fetch implementation.

Await syntax

If you like to await promises in Svelte templates, then you can use ready store from the request object:

<script lang="ts">
  import { findPetByStatus } from 'src/api.ts'
  const request = findPetByStatus({ status: 'sold' })
  const petsReady = request.ready
</script>

<div>
  {#await $petsReady}
    <p>Loading..</p>
  {:then resp}
    {#if resp.ok}
      {#each resp.data as pet}
        <p>{pet.name}</p>
      {/each}
    {:else}
      <p>Error while loading pets</p>
    {/if}
  {/await}

  <button on:click={() => {request.reload()}}>
    Reload pets
  </button>
</div>
Enter fullscreen mode Exit fullscreen mode

Autosuggestion works there as well!

Image description

Retrying a request

The curious reader has noticed that API call doesn't return a response directly, but a request object. It's designed to satisfy different needs:

  • subscribe to a ready store and wait for updates
  • await for response in template
  • await the result promise right away (for example in server code)
  • trigger a reload of the same request with the same parameters

For example you might want to periodically refresh the page content, and the parameters of such request stay the same:

import type { components } from 'src/petstore'

let pets: components['schemas']['Pet'][] = []
const request = findPetsByStatus({ status: 'available' })

request.resp.subscribe((resp) => {
    if (resp && resp.ok) {
        pets = resp.data
    }
})

setInterval(() => {
    request.reload()
}, 5000)
Enter fullscreen mode Exit fullscreen mode

This example also shows that you can access backend types programmatically by importing them:

Image description

Error handling

Errors can be expected and unexpected. First ones usually have an HTTP status code starting from 4, e.g 404 when a resource is not found on server, or 422 when your payload is incorrect. Sometimes you even have special handlers for them on frontend side. Often these requests have a JSON body in order to indicate what was wrong.
However under certain conditions a request can fail unexpectedly, for example when backend is down, or due to unstable network. These requests might not have a body, they usually throw an error on client side.

We thought that it's a good idea to provide a developer a single interface for handling both kinds of errors. Thus, each response has ok, status and error properties to debug the issue properly.

  • ok is set to false for every failed request
  • status is a HTTP status code and it's set by backend most of the times, but for exceptional cases like network errors it will be a negative value like -1
  • error contains a JSON body from backend if provided or set to undefined otherwise.

Please always care about error cases to build better services!

Bonus - pre-commit hooks

Now you have a mechanism to generate frontend types from OpenAPI spec, so backend becomes a single source of truth and you don't need to repeat a lot of code by hands. But there's still no answer on how to keep types in sync. One of the possible approaches is to use pre-commit hooks, like pre-commit or Husky.

Pre commit hooks are set of commands to execute every time you run git commit. If you have a monorepo, then you can configure hooks to export OpenAPI spec and run openapi-typescript consequently. If your spec is accessible over network, then just create an npm script with a URL as a parameter. Possible example for Petsore:

package.json:

{
  "devDependencies": {
    "openapi-typescript": "^6.2.0",
  },
  "scripts": {
    "make-api": "openapi-typescript https://petstore3.swagger.io/api/v3/openapi.json --output src/lib/openapi.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

.pre-commit-config.yaml:

repos:
  - repo: local
    hooks:
      - id: make-api
        name: make-api
        entry: npm run make-api
        language: system
        pass_filenames: false

Enter fullscreen mode Exit fullscreen mode

Conclusion

We hope that managing your API requests will be easier now thanks to Apity. We already use it in our SvelteKit projects and the experience is rather fun so far. Please try it out if you find this approach interesting enough, and your feedback is always appreciated.

Also I built a simple demo application using same Petstore spec, you can check it out at https://apity-demo.vercel.app.
Each page has a source code snippet for your convenience.

Thanks for reading!

Top comments (2)

Collapse
 
romaindurand profile image
Romain Durand

Apity seems cool but i don't see a reason why you wouldn't use fets instead : npmcompare.com/compare/@cocreators...

Collapse
 
erebos-manannan profile image
Erebos Manannán

One great thing about the types is that you can then use the various types in the API when specifying function arguments down the line and get code completion pretty far from the API calls too.