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

Complex attribute values (json, array) #4

Closed
MortalFlesh opened this issue Jun 15, 2020 · 6 comments
Closed

Complex attribute values (json, array) #4

MortalFlesh opened this issue Jun 15, 2020 · 6 comments

Comments

@MortalFlesh
Copy link

Hello,

is it possible to define an attribute as a JsonObject or an array?

I'd like to have a resource with attributes like this:

{
    ...
    "attributes": {
        "contact": {
            "email": "<email>",
            "phone": "<phone>"
        }
    }
}

Thank you.

@cmeeren
Copy link
Owner

cmeeren commented Jun 15, 2020

Yes. In Felicity, you are free to return whatever you want. For example, if you use define.Attribute.Simple(), your objects will be serialized and deserialized exactly as-is.

However, please consider if this is the optimal resource design. Why not have email and phone as direct resource attributes, so they can be updated individually? (In your example, there is no way to update just email or just phone; you have to replace the whole object, because JSON:API doesn't allow for anything else.) This also goes for sparse fieldsets. Alternatively, you could create a contact resource that has email and phone as direct attributes, and have a relationship to the contact resource from the resource you are showing above.

While JSON:API allows attributes to be objects, things tend to be simpler if you keep things flat.

@MortalFlesh
Copy link
Author

Hello,

yes, I know that flatten structure is easier to handle, but I need a Domain specific types to be there.

Contact is a part of ubiquitous language and as a value object (so it should not be a separate resource I'd say) it cannot be changed partially. Also it should be skipped in the response, where only a resource id should be.

But I still can't handle it.
I was just able to handle a simple string, in the way you described, but it is not what I want.

{
    ...
    "attributes": {
        "contact": "... contact ..."
    }
}

but when I send a

{
    ...
    "attributes": {
        "contact": {
            "email": "<email>",
            "phone": "<phone>"
        }
    }
}

I could not get a data - I always get the error

{
  "errors": [
    {
      "id": "86683d8b55e54cd18a26a6520af40c92",
      "status": "400",
      "title": "Invalid request body",
      "detail": "Invalid JSON for attribute 'contact': Cannot get the value of a token type 'StartObject' as a string.",
      "source": {
        "pointer": "/data/attributes/contact"
      }
    }
  ]
}

@cmeeren
Copy link
Owner

cmeeren commented Jun 17, 2020

Hm, not sure what's wrong, but it may look like it infers the attribute type to be string. Could you share the Felicity code for this attribute definition, and any signatures for the functions you're using in the attribute definition?

@MortalFlesh
Copy link
Author

Well it might be the problem.

TLDR

I was not sure what should be the type of such attribute. So I had a string there. I've changed it to obj and in the end to System.Text.Json.JsonElement now, and it works.


Business Logic

This API is for identifying persons by contact - client send (POST) contact (json above) and gets PersonId (it would create a person if there is not one yet). So the contact should be skipped in the response.

This is my code

a bit simplified, but basically this ...

// Domain

type PersonId = PersonId of string
type Email = Email of string
type Phone = Phone of string

type Contact =
    | IsEmail of Email
    | IsPhone of Phone
    | IsEmailAndPhone of Email * Phone

type Identity = {
    Id: PersonId
    Contact: Contact
}

// Resource
let contact =
        define.Attribute
            .Simple()
            .GetSkip(fun ctx identity ->
                printfn "skip: %A" identity
                Skip
            )
            .Set(fun (contact: obj) identity ->
                printfn "set: %A (%A)" c (c.GetType())
                identity
            )

let post =
        define.Operation
            .Post(fun ctx parser ->
                let require attribute =
                    attribute
                    |> parser.GetRequired
                    |> Hopac.Job.toAsync <@> AttributeErrors

                asyncResult {
                    let! (contact: obj) = require contact
                    printfn "contact in post: %A (%A)" contact (contact.GetType())

                    (* let! contact =
                        contact
                        |> Contact.parseString
                        |> AsyncResult.ofResult <@> ParseContactError *)

                    // I'd like to parse contact here, but now I just create a dummy one, to make it all pass and log only
                    let contact = IsEmail (Email "... dummy email ...")

                    return {
                        Id = PersonId "... todo ..."
                        Contact = contact
                    }
                }
                |> parser.ForAsyncRes
            )
            .AfterCreate(fun identity -> identity)

where <@> is AsyncResult.mapError

for

{
    "data": {
        "type": "identities",
        "attributes": {
            "contact": { "email": "" }
        }
    }
}

the output is:

Post ...
contact in post: ... contact ... (System.Text.Json.JsonElement)
set: { "email": "" } (System.Text.Json.JsonElement)
skip: { Id = PersonId "...todo..."
  Contact = IsEmail Email "...email..." }

So when I've changed obj to System.Text.Json.JsonElement, it works and I can handle it myself.

Thanks for help!

@cmeeren
Copy link
Owner

cmeeren commented Jun 18, 2020

The type used in Simple is what is directly serialized and deserialized. You should in general not use a DU type here, because they have no standard JSON representation. I can not guarantee there won't be breaking changes if you use Simple with types that do not have standard JSON representations.

In fact you should ideally not use a domain type directly at all, because if you do, you need to be aware that the domain object is serialized directly, and you can't rename/refactor your domain code at will without risking breaking clients.

The ideal solution in your case is to create a record type for the JSON representation, e.g.

type ContactDto =
  { email: string option
    phone: string option }

(option is fine, it will be serialized as expected, as will Skippable, but you can also just use string and check for nulls)

and then map between this DTO and whatever else you have in your domain logic. That way, you have a stable JSON representation that won't accidentally change if you refactor/rename domain stuff.

You should not need to use JsonElement.

Again though, you may want to factor contact out to a separate resource. I know you said it's a value object in your domain code, but the API representations need not (and in many cases should not) match the domain code (or DB schema) one-to-one. JSON:API, domain code and DB storage are very different beasts often requiring different approaches to modeling.

If you are thinking "what would the ID of the contact resource be", then if an Identity has a single Contact as shown in your code, you can just use the Identity ID for the contact, too.

@MortalFlesh
Copy link
Author

Ohh.. Now I get it :)
The ContactDto type is basically what I needed from the begging.

Thank you! 👍

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