Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Question] Unbox in records #42

Open
ashton opened this issue Apr 17, 2023 · 10 comments
Open

[Question] Unbox in records #42

ashton opened this issue Apr 17, 2023 · 10 comments

Comments

@ashton
Copy link

ashton commented Apr 17, 2023

Hey! I tried to find this in documentation or in previous issues but couldn't find anything, so sorry if it's not a proper issue.

Is there any way that I can "unbox" a nested record?

If for example, I have the following in rescript:

module Address = {
  type t = {
    street: string,
  }
}

type user = {
  name: string,
  address: Address.t
}

and using ppx_spice, somehow I get an encoder that creates the following JSON with an user record:

{
  "name": "foo",
  "address_street": "bar"
}
@ashton ashton changed the title [Question] [Question] Unbox in records Apr 17, 2023
@mununki
Copy link
Member

mununki commented Apr 19, 2023

Can you give me a self-contained example?

module Address = {
  @spice
  type t = {street: string}
}

@spice
type user = {
  name: string,
  address: Address.t,
}

let user = {name: "wk", address: {street: "munjung"}}
Js.log2("user", user)

let user2 = user->user_encode
Js.log2("user2", user2)
let user3 = switch user2->user_decode {
| Ok(user) => user
| Error(_) => assert false
}
Js.log2("user3", user3)
user {"address": {"street": "munjung"}, "name": "wk"}
user2 {"address": {"street": "munjung"}, "name": "wk"}
user3 {"address": {"street": "munjung"}, "name": "wk"}

It seems fine to me

@ashton
Copy link
Author

ashton commented Apr 23, 2023

Hey @mununki , thanks for the response. Yes, that is the default behavior, but I'm not sure if you noticed, but the address field is not nested in my example. In my case I'll receive data all de-normalized in the response, like address_street aforementioned in the user. I couldn't find how to create a custom codec to turn many fields in the json (like address_street, address_number etc..) into a nested record when decoding and vice versa, when encoding turning a single attribute (of a nested record) into many different fields in the json result.

@mununki
Copy link
Member

mununki commented Apr 23, 2023

How about using @spice.key(...) for the fields?

@mununki
Copy link
Member

mununki commented Apr 23, 2023

@spice
type t = {
  name: string,
  @spice.key("address_street") addressStreet: string,
}

@ashton
Copy link
Author

ashton commented Apr 24, 2023

I think that the problem remains the same, my biggest issue is that all those decorators have the premise of the json schema being 1:1 to the record model in rescript, and that's not true in my use case, and that is totally ok too, if the default behavior don't work for me, I have no problem in creating custom encoders/decoders manually, I'm just trying to figure it out how.

I think that the case you mentioned above wouldn't work because it would create an Adress.decode_t function, and I would also have an User.decode_t both to decode the same JSON, how would I do that? Pass the same json object to both functions and nesting them manually later?

@mununki
Copy link
Member

mununki commented Apr 24, 2023

Can you elaborate your data and type definition you want decode json to? Concise example would be better to understand. Let me help you to make custom (d)encoder.

@ashton
Copy link
Author

ashton commented Apr 27, 2023

I tried to do that in the issue description, not sure if I could made myself clear enough. I tried to create a concise example with only 2 fields. I'm consuming an api that have the following JSON schema representing an user:

{"name": "My User Name"
 "address_street": "my street",
 "address_number": 42}

I can mirror this same structure in my rescript code and use the @spice decorator to decode this data automatically, but I would like to organize my models better than the API that I'm consuming, so I wanted to have the address data in a different model, that would look like the following structure:

type address = {
  street: string,
  number: int,
}

type user = {
  name: string,
  address: address
}

And I would like to build an encoder/decoder to translate this for me, so for example:

let response: Js.Json.t = myApiCall() // the response is the same JSON object described above
let user = response->myMagicDecoder

Js.Console.log(user.name) // My User Name
Js.Console.log(user.address.street) // my street
Js.Console.log(user.address.number) // 42

This is the result that I want to achieve but I couldn't find out just looking at the documentation, because of the reasons that I tried to describe in the previous comments, in the JSON data I have only 1 dictionary with all the fields, but some fields have the same "suffix" (like address as in address_street and address_number), and I would like to decode all of these fields in a new nested record.

In this example if I decorate my user type and my address type with @spice (it was your first suggestion), it would generate 2 different decoders right? One for each type, but I still would have only 1 json object to be decoded, how could I make this work? I would have to pass the same json data to both decoders, hoping that they ignore the extra fields and have each model decoded separately and then nest them manually. As for your second suggestion, using the @spice.key decorator, wouldn't also help me to achieve my desired structure, as your example shows I wouldn't have a user record with an address attribute of type address, I would have a user record with an addressStreet attribute as string and an addressNumber as int.

Is it clearer now?

@mununki
Copy link
Member

mununki commented May 5, 2023

Sorry for the late reply. I think this is definitely possible with a custom (d)encoder. I'll try to come up with some code tonight or tomorrow.

@mununki
Copy link
Member

mununki commented May 5, 2023

There are three possible ways to do this

  1. if a structure with a parent higher than user is possible, e.g. {"data": user}
@spice
type address = {
  street: string,
  number: int,
}

@spice
type user = {
  name: string,
  address: address,
}

let encoderUser = (v: user) => {
  let streetJson = Js.Json.string(v.address.street)
  let numberJson = Js.Json.number(v.address.number->Int.toFloat)
  let nameJson = Js.Json.string(v.name)
  [("name", nameJson), ("address_street", streetJson), ("address_number", numberJson)]
  ->Js.Dict.fromArray
  ->Js.Json.object_
}
let decoderUser = json =>
  switch json->Js.Json.classify {
  | JSONObject(dict) => {
      let name = dict->Js.Dict.get("name")->Option.map(Js.Json.classify)
      let addressStreet = dict->Js.Dict.get("address_street")->Option.map(Js.Json.classify)
      let addressNumber = dict->Js.Dict.get("address_number")->Option.map(Js.Json.classify)
      switch (name, addressStreet, addressNumber) {
      | (
          Some(Js.Json.JSONString(name)),
          Some(Js.Json.JSONString(street)),
          Some(Js.Json.JSONNumber(number)),
        ) =>
        Ok({name, address: {street, number: number->Int.fromFloat}})
      | _ => Error({Spice.path: "", message: "Expected name, street, number ", value: json})
      }
    }
  | _ => Error({Spice.path: "", message: "Expected JSONObject", value: json})
  }

let codecUser: Spice.codec<user> = (encoderUser, decoderUser)

@spice
type data = {user: @spice.codec(codecUser) user}

let data = %raw(`
{
  "user" : {
    "name": "woonki",
    "address_street": "Munjung",
    "address_number": 8
  }
}
`)

let data = data->data_decode
let json = data->Result.getExn->data_encode

You can create and utilize a custom codec for values of type user.

  1. If a structure with a parent higher than user is not possible: Create a type for a stopover, such as type userRaw.
@spice
type address = {
  street: string,
  number: int,
}

@spice
type user = {
  name: string,
  address: address,
}

@spice
type userRaw = {
  name: string,
  address_street: string,
  address_number: int,
}

let data = %raw(`
{
  "name": "woonki",
  "address_street": "Munjung",
  "address_number": 8
}
`)

let data =
  data
  ->userRaw_decode
  ->Result.map(userRaw => {
    name: userRaw.name,
    address: {street: userRaw.address_street, number: userRaw.address_number},
  })

let json = data->Result.map(user =>
  {
    name: user.name,
    address_street: user.address.street,
    address_number: user.address.number,
  }->userRaw_encode
)
  1. ReScript v11's Untagged union to model and interop directly: It's still in alpha release, but you can certainly try.

Hope this helps.

@mununki
Copy link
Member

mununki commented May 5, 2023

I've added the example for the solution 1, 2 in the examples directory. You can run on your local https://github.com/green-labs/ppx_spice/blob/main/examples/src/CustomCodecs2.res https://github.com/green-labs/ppx_spice/blob/main/examples/src/CustomCodecs3.res

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants