Modeling create/get/patch APIs in rescript

Hi there,

As the title implies I’m a bit stuck on how to best model APIs that support creating objects (with a certain shape of optional vs non-optional fields), getting objects (with another shape of optional vs non-optional fields) and patching objects (where nearly all fields are optional) and being able to re-use as much of the types/codecs as possible.

We’ve settled on Jzon to do our JSON (de)serialisation for now but are a bit frustrated with our current implementation that only really allows for the decoding side of the defined codec.

At our company we use Feathers which has the concept of services which provide a standard way to create and query resources, below is my attempt at binding to these services.

module type ServiceSpec = {
  type item

  type patch
  type create
  type query

  let codec: Jzon.codec<item>
  let path: string
}

module MakeService = (Service: ServiceSpec) => {
  // bindings to our client library
}

The only type that is required to be well-defined is the item type as you have to write a decoder for it, the patch and create type can be whatever makes sense for that service, in general both will be @deriving(abstract) types with a number of @optional decorated fields. This has been working for us so far up until we have nested rescript types, e.g.

module SomeServiceSpec = {
  type nested = Attribute(string)

  type item = {
    id: string,
    nested: array<nested>,
  }

  let nestedCodec = Jzon.object1(
    nested =>
      switch nested {
      | Attribute(attribute) => attribute
      },
    attribute =>
      switch attribute {
      | "value1"
      | "value2"
      | "value3" =>
        Attribute(attribute)->Ok
      | _ =>
        Error(#UnexpectedJsonValue([Jzon.DecodingError.Field("attribute")], "Invalid attribute"))
      },
    Jzon.field("attribute", Jzon.string),
  )

  let codec = Jzon.object2(
    obj => (
      obj.id,
      obj.nested,
    ),
    ((
      id,
      nested,
    )) =>
      {
        id: id,
        nested: nested,
      }->Ok,
    Jzon.field("id", Jzon.string)->Jzon.optional,
    Jzon.field("nested", Jzon.array(nestedCodec))->Jzon.default([]),
  )

   @deriving(abstract)
  type patch = {
    @optional nested: array<{"attribute": string}>,
  }

  @deriving(abstract)
  type create = {
    nested: array<{"attribute": string}>,
  }

  let path = "..."
}

In this case the nested field of the item is a variant. This means that the patch (and create) type need to be defined differently as array<{"attribute": string}> and need to be translated from the variant at the call site of patch and create each time, when we could be using the coder part of the codec. If we do that, we need to change the type of nested to Js.Json.t which means anything could go in there and we lose a bit of safety.

Is there a way to create a version of the Js.Json.t type that requires the caller to use a certain codec to create it?

Or alternatively, I imagine every web project would come across this problem, is there just a better way of approaching this?

Thanks

I’d stop using types with @deriving(abstract) and have only two types: item and partialItem, and two separate codecs for them. It should solve the problem, because you’ll start working with Js.Json.t and types I’ve described.

As you’ve mentioned that there’s a problem with codec reusability, but I think if you move jzon fields to separate variables it’ll reduce the problem to some extent.

I use my own library ReScript Struct instead of Jzon, but I hope you can see the idea. Tell me if I didn’t understand the problem correctly.

module StructExtra = {
  external unsafeAnyToFieldsArray: 'any => array<S.field<S.unknown>> = "%identity"

  let partialRecordFactory = (~fields) => {
    let fieldsArray = fields->unsafeAnyToFieldsArray
    S.Record.factory(
      ~fields=fieldsArray->Js.Array2.map(((fieldName, fieldStruct)) => (
        fieldName,
        S.option(fieldStruct),
      )),
    )
  }

  let partialRecord2: (
    ~fields: (S.field<'v1>, S.field<'v2>),
    ~constructor: ((option<'v1>, option<'v2>)) => result<'value, string>=?,
    ~destructor: 'value => result<(option<'v1>, option<'v2>), string>=?,
    unit,
  ) => S.t<'value> = partialRecordFactory
}

module Nested = {
  type t = Attribute(string)
}

module Service = {
  type item = {
    id: string,
    nested: array<Nested.t>,
  }
  @deriving(abstract)
  type partialItem = {@optional nested: array<Nested.t>}

  let fields = (
    ("id", S.string()),
    (
      "nested",
      S.array(
        S.record1(
          ~fields=("attribute", S.string()),
          ~destructor=value =>
            {
              switch value {
              | Nested.Attribute(attribute) => attribute
              }
            }->Ok,
          (),
        ),
      ),
    ),
  )

  let struct = S.record2(~fields, ~destructor=({id, nested}) => (id, nested)->Ok, ())
  let partialStruct = StructExtra.partialRecord2(
    ~fields,
    ~destructor=partialItem => (None, partialItem->nestedGet)->Ok,
    (),
  )
}

let createInput: Service.item = {id: "foo", nested: [Nested.Attribute("bar")]}
Js.log(createInput->S.destructWith(Service.struct))
// Output: Ok({"id":"foo","nested":[{"attribute":"bar"}]})

let patchInput: Service.partialItem = Service.partialItem(~nested=[Nested.Attribute("bar")], ())
Js.log(patchInput->S.destructWith(Service.partialStruct))
// Output: Ok({"nested":[{"attribute":"bar"}]})