[ANN] rescript-rest - RPC-like client, contract, and server implementation for a pure REST API

Creating and consuming REST APIs was a neglected part of the ReScript ecosystem, and since the first days of using ReScript, I have been thinking about how to solve the problem. This is how I started creating rescript-schema and rescript-json-schema. After a long break, I collected all my OSS experience, took inspiration from ts-rest, and started building the new rescript-rest with the goal of creating an ultimate tool for working with REST API in ReScript with top-notch DX.

And today, I want to announce the first Beta version of the library rescript-rest. It currently covers very little scope of problems I plan to solve, but that’s the first step for a great library that is already usable to some extent.

Basic usage

Create Contract.res and define your routes:

// Contract.res

let createGame = Rest.route(() => {
  path: "/game",
  method: "POST",
  schema: s => {
    "userName": s.field("user_name", S.string),
  },
})

:brain: Currently rescript-rest supports only client, but the idea is to reuse the file both for client and server.

Now you can use the contract to perform type-safe calls to your server:

// Client.res

let client = Rest.client(~baseUrl="http://localhost:3000")

let _ = await client.call(Contract.createGame, ~variables={"userName": "Dmitry"})

Planned features

  • Support path params
  • Support query params
  • Implement type-safe response
  • Support passing headers and fetch options
  • Generate OpenAPI from Contract
  • Generate Contract from OpenAPI
  • Integrate with Fastify on server-side

I have a vision of how the library should look in the end, but one of the reasons I posted the announcement this early is to get your feedback, so feel free to leave any thoughts you have. Also, I will use the thread to post new versions with new features :blush:

12 Likes

In the end, the tool should support the majority of ts-rest features, have a more ergonomic design for ReScript users, have a tree-shakable contract, and be more convenient for passing body/params/query - as a single variables value where you can use variants. While in ts-rest you should use separate fields for them.

1 Like

This especially would be a fantastic addition to the ecosystem. I look forward to following your progress!

2 Likes

Very interesting! Thank you, for sharing your work!

I was wondering if there were specific reasons you chose string for path and method?

In case you haven’t come across this project: I really enjoy using GitHub - anuragsoni/routes: typed bidirectional router for OCaml/ReasonML web applications

2 Likes

For the last few months, I have had to pause the development of rescript-rest because an important feature was missing in my other library, rescript-schema. I finally managed to implement it, and I am happy to announce the release of ReScript Schema V7 as well as the continuation of rescript-rest development.

ReScript Schema V7 :fire:

S.object superpowers :superhero:

I needed this for rescript-rest, and I hope it will be useful for many other rescript-schema users.

s.flatten

Now, it’s possible to spread/flatten an object schema in another object schema, allowing you to reuse schemas in a more powerful way.

type entityData = {
  name: string,
  age: int,
}
type entity = {
  id: string,
  ...entityData,
}

let entityDataSchema = S.object(s => {
  name: s.field("name", S.string),
  age: s.field("age", S.int),
})
let entitySchema = S.object(s => {
  let {name, age} = s.flatten(entityDataSchema)
  {
    id: s.field("id", S.string),
    name,
    age,
  }
})

s.nestedField

A new nice way to parse nested fields:

let schema = S.object(s => {
  {
    id: s.field("id", S.string),
    name: s.nestedField("data", "name", S.string)
    age: s.nestedField("data", "name", S.int),
  }
})

Object destructuring

Also, it’s possible to destructure object field schemas inside of the definition. You could also notice it in the s.flatten example :grin:

let entitySchema = S.object(s => {
  let {name, age} = s.field("data", entityDataSchema)
  {
    id: s.field("id", S.string),
    name,
    age,
  }
})

:brain: While the example with s.flatten expect an object with the type {id: string, name: string, age: int}, the example above and with s.nestedField will expect an object with the type {id: string, data: {name: string, age: int}}.

Extend field with another object schema

You can define object field multiple times to extend it with more fields:

let entitySchema = S.object(s => {
  let {name, age} = s.field("data", entityDataSchema)
  let additionalData = s.field("data", s => {
    "friends": s.field("friends", S.array(S.string))
  })
  {
    id: s.field("id", S.string),
    name,
    age,
    friends: additionalData["friends"],
  }
})

:brain: Destructuring works only with not-transformed object schemas. Be careful since it’s not protected by type system.

Autocomplete improvements :keyboard:

Updated context type names to s for better auto-complete in your IDE.

  • effectCtx → s
  • Object.ctx → Object.s
  • Tuple.ctx → Tuple.s
  • schemaCtx → Schema.s
  • catchCtx → Catch.s

S.json redesign :minidisc:

Added unsafe mode for S.json:

  • S.json → S.json(~validate: bool)
  • More flexible
  • Improved tree-shaking
  • Tools using rescript-schema can get the info from the tagged type: JSON → JSON({validated: bool})

Other cool changes and sometimes breaking :bomb:

  • Added serializeToJsonStringOrRaiseWith

  • Allow to create S.union with a single item

  • Removed s.failWithError. Use S.Error.raise instead

  • PPX: Removed @schema for type expressions. Use @s.matches instead.

  • Removed async support for S.union. Please create an issue if you used the feature

  • Improved parsing performance of S.array and S.dict ~3 times

  • Automatic serializing stopped working for tuples/objects/unions of literals. Use S.literal instead

  • Removed InvalidTupleSize error code in favor of InvalidType

  • Changed payload of Object and Tuple variants in the tagged type

  • Redesigned Literal module to make it more efficient

    • The S.Literal.t type was renamed to S.literal, became private and changed structure. Use S.Literal.parse to create instances of the type
    • S.Literal.classify → S.Literal.parse
    • S.Literal.toText → S.Literal.toString. Also, started using .toString for Function literals and removed spaces for Dict and Array literals to make them look the same as the JSON.stringify output
  • Moved built-in refinements from nested modules to improve tree-shaking:

    • S.Int.min → S.intMin

    • S.Int.max → S.intMax

    • S.Int.port → S.port

    • S.Float.min → S.floatMin

    • S.Float.max → S.floatMax

    • S.Array.min → S.arrayMinLength

    • S.Array.max → S.arrayMaxLength

    • S.Array.length → S.arrayLength

    • S.String.min → S.stringMinLength

    • S.String.max → S.stringMaxLength

    • S.String.length → S.stringLength

    • S.String.email → S.email

    • S.String.uuid → S.uuid

    • S.String.cuid → S.cuid

    • S.String.url → S.url

    • S.String.pattern → S.pattern

    • S.String.datetime → S.datetime

    • S.String.trim → S.trim

    • S.Int.min → S.intMin

    • S.Int.max → S.intMax

    • S.Number.max → S.numberMax/S.integerMax

    • S.Number.min → S.numberMin/S.integerMin

7 Likes

Can’t wait to see what you come up with with these changes

Pretty much the whole API for the library :arrow_up:︎

2 Likes

:new: rescript-rest@0.2.0 is out!

Now, it’s possible to define headers and query params with superb DX and total type-safety.
Hence, here’s the updated Super Simple Example from the docs:

6 Likes

:new: rescript-rest@0.3.0 is out!

  • Made ~variables in the call function a non-labeled argument
  • Added support for path parameters

Define your API contract somewhere shared, for example, Contract.res:

let getPost = Rest.route(() => {
  path: "/posts/:id",
  method: "GET",
  variables: s => s.param("id", S.string),
})

Consume the api on the client with a RPC-like interface:

let client = Rest.client(~baseUrl="http://localhost:3000")

let result = await client.call(
  Contract.getPost,
  "123"
  // ^-- Fully typed!
) // ℹ️ It'll do a GET request to http://localhost:3000/posts/123
3 Likes

:new: rescript-rest@0.4.1 released!

It comes with type-safe responses support. There’s still a big list of possible features, but from this moment, I’d call rescript-rest ready to be used in real projects :fire:

Check the updated documentation: GitHub - DZakh/rescript-rest: ReScript RPC-like client, contract, and server implementation for a pure REST API
I’ve included examples for different use cases.

Here’s the part for Responses:

Responses

Responses are described as an array of response definitions. It’s possible to assign the definition to a specific status using s.status method.

let createPost = Rest.route(() => {
  path: "/posts",
  method: "POST",
  variables: _ => (),
  responses: [
    s => {
      s.status(#201)
      Ok(s.data(postSchema))
    },
    s => {
      s.status(#404)
      Error(s.field("message", S.string))
    },
  ],
})

You can use s.status multiple times. To define a range of response statuses, you may use 1XX, 2XX, 3XX, 4XX and 5XX. If s.status is not used in a response definition, it’ll be treated as a default case, accepting a response with any status code.

let createPost = Rest.route(() => {
  path: "/posts",
  method: "POST",
  variables: _ => (),
  responses: [
    s => {
      s.status(#201)
      Ok(s.data(postSchema))
    },
    s => {
      s.status(#404)
      Error(s.field("message", S.string))
    },
    s => {
      s.status(#"5XX")
      Error("Server Error")
    },
    s => Error("Unexpected Error"),
  ],
})
3 Likes

Also, there’s a cool feature to define response headers, which doesn’t exist in ts-rest :raised_hands:

:point_down::point_down::point_down:

Responses from an API can include custom headers to provide additional information on the result of an API call. For example, a rate-limited API may provide the rate limit status via response headers as follows:

HTTP 1/1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 2016-10-12T11:00:00Z
{ ... }

You can define custom headers in a response as follows:

let ping = Rest.route(() => {
  path: "/ping",
  method: "GET",
  summary: "Checks if the server is alive",
  variables: _ => (),
  responses: [
    s => {
      s.status(#200)
      s.description("OK")
      {
        "limit": s.header("X-RateLimit-Limit", S.int->S.description("Request limit per hour.")),
        "remaining": s.header("X-RateLimit-Remaining", S.int->S.description("The number of requests left for the time window.")),
        "reset": s.header("X-RateLimit-Reset", S.string->S.datetime->S.description("The UTC date/time at which the current rate limit window resets.")),
      }
    }
  ],
})
2 Likes

ReScript Schema V8 is out :dna:

Faster, more reliable, more flexible.

Full changelog here: https://github.com/DZakh/rescript-schema/releases/tag/v8.0.0

And after a break I’m back working on rescript-rest :sweat_smile:

4 Likes

Some progress towards ReScript codegen from OpenAPI :building_construction:
Finished adding types for all objects in the OpenAPI v3.1 specification and released rescript-openapi@0.2.0

5 Likes