stasp
stasp

Reputation: 70

Handling complex JSON with enums and variable sub-JSON schemas

I have a complex JSON which can have variable sub-JSONs inside it, each with their own schema. Here is an illustration of its structure:

[
  "node_level": "unit",
  "name": "unit 1",
  "description": "the first unit",
  "theme_image_path": "hello.png",
  "children": [
    {
      "node_level": "chapter",
      "name": "chapter 1",
      "theme_image_path": "dog.png",
      "children": [
        {
          "node_level": "lesson",
          "lesson_type": "reading",
          "children": [
            {
              "node_level": "activity",
              "activity_type": "vocabulary",
              "elements": { /* this bit varies systematically according to activity_type */ }
            }
          ]
        }
      ]
    }
  ]
]

Note that the activity level could be one of a number of different object schemas - effectively each activity type has its own schema that should fit in here, with activity_type and elements varying.

Here is my current implementation, which is both not working with the purescript-argonaut-codecs library (error shown below), and not fulfilling some of my aims (detailed below) even if it would work.

import Data.Argonaut.Encode (encodeJson)

-- This is raising errors atm
contentToJson :: Content -> Json
contentToJson = encodeJson

type UnitNode = 
  { node_level :: String -- would prefer this value to be enforced to "unit" (see point 3 below)
  , name :: String
  , description :: String
  , theme_image_path :: String
  , children :: Array ChapterNode
  }

type ChapterNode = 
  { node_level :: String -- prefer enforced to "chapter" (point 3)
  , name :: String
  , theme_image_path :: String
  , children :: Array LessonNode
  }

type LessonNode = 
  { node_level :: String -- prefer enforced to "lesson" (point 3)
  , lesson_type :: String -- would prefer an enum-like situation (see point 1 below)
  , children :: Array ActivityNode
  }

type ActivityNode = 
  { node_level :: String -- prefer enforced to "activity" (point 3)
  , layout :: String -- prefer enum-like (point 1)
  , elements :: VocabFlashcard -- this should be one of many types (see point 2 below)
  }

type VocabFlashcard =
  { audio :: String
  , image :: String
  , vocabText :: String
  , translation :: String
  }

There are a few things I am struggling with getting done in Purescript. They are mostly doable in Typescript, so if I can't do them in Purescript I will consider switching back...

1. Enum-like fields

Some fields (node_level, lesson_type, activity_type) take strings which are of a given closed subset, e.g. node_level can only be one of "unit", "chapter", "lesson", or "activity". With TS I would do something like this:

type NodeLevel = "unit" | "chapter" | "activity" | "lesson"

With Purescript I have tried two methods, they both have their problems:

  1. Use newtype
  2. Use an enum type like data NodeLevel = UnitLevel String | ChapterLevel String | ActivityLevel String | LessonLevel String and then separately define the strings appropriate to each (this is pretty verbose)

The first method is ok without the codec, but the codec library gives me the following error:

No type class instance was found for

    Data.Argonaut.Encode.Class.EncodeJson LessonType


while solving type class constraint

  Data.Argonaut.Encode.Class.GEncodeJson ( children :: Array
                                                         { elements :: { audio :: String
                                                                       , image :: String
                                                                       , translation :: String
                                                                       , vocabText :: String
                                                                       }
                                                         , layout :: String
                                                         , node_level :: String
                                                         }
                                         , lesson_type :: LessonType
                                         , node_level :: String
                                         )
                                         (Cons @Type "lesson_type" LessonType (Cons @Type "node_level" String (Nil @Type)))

while checking that type forall (@a :: Type). EncodeJson a => a -> Json
  is at least as general as type Array
                                   { children :: Array ...
                                   , description :: String
                                   , name :: String
                                   , node_level :: String
                                   , theme_image_path :: String
                                   }
                                 -> Json
while checking that expression encodeJson
  has type Array
             { children :: Array ...
             , description :: String
             , name :: String
             , node_level :: String
             , theme_image_path :: String
             }
           -> Json
in value declaration contentToJson

The second also works, although it is verbose. Unfortunately, I run into a similar error as before:

No type class instance was found for Data.Argonaut.Encode.Class.EncodeJson NodeLevel ...

2. Variable sub-schemas

In TS, I would do something like this:

type Lesson = {
  node_level: NodeLevel;
  children: Activity[];
  lesson_type: LessonType;
}

type ActivityName =
  "vocab"
  | "grammar"
  | (... etc)

type Activity =
  Vocabulary
  | Grammar
  | (... etc)

type Vocabulary = {
  node_level: NodeLevel,
  activity_type: ActivityName,
  elements: {
    audio: string,
    image: string,
    vocabText: string,
    translation: string
  },
}

-- etc. for more Activity options

This ensures that the array of Activity does indeed contain specifically this schema. How to do this in Purescript? Purescript doesn't seem to accept types being one of several possibilities using the | operator, unless you use data (is that what I should be doing? How? It seems it would run into the error from point 1 above).

3. Enforced values

My JSON should ideally have specific values of node_level depending on the sub-schema / level of the hierarchy, e.g. Vocabulary should have node_level: "activity" enforced.

Upvotes: 1

Views: 39

Answers (1)

Fyodor Soikin
Fyodor Soikin

Reputation: 80744

It seems from your question that you have elected not to study the base concepts and mechanics of PureScript (such as e.g. type classes or ADTs) before attempting to write a program, and this makes providing an answer nearly impossible, for you cannot teach a man to fish before they have even a concept of what an ocean is. And there is definitely not enough space here to provide a full PureScript 101 lesson.

Therefore, I will answer the question as stated and pretend that you do know how PureScript actually works, with the expectation that you would fill the gaps in your knowledge as needed.


First of all, because your "nodes" are not distinct types, but merely type aliases, they cannot have type class instances, and in order to use encodeJson, you need such instance. Therefore, the first order of business is to make those distinct types. And since "logically" they are just records, a newtype wrapping a record would do:

newtype UnitNode = UnitNode
  { name :: String
  , description :: String
  , theme_image_path :: String
  , children :: Array ChapterNode
  }

Note that I have omitted the node_level field here. This is on purpose. In TypeScript you need this field to determine what the type is, but in PureScript all types are known statically, so an additional indication is not required.

Now that UnitNode is a distinct type, we can write an EncodeJson instance. The only method of the EncodeJson class is encodeJson, which is supposed to, well, encode the value as JSON. The value in our case being UnitNode.

Since all record fields exactly match what they should be in JSON already, we can just unwrap the record and use the EncodeJson instance for records to encode it. Easy:

instance EncodeJson UnitNode where
  encodeJson (UnitNode theRecord) = encodeJson theRecord

But this doesn't quite accomplish what you want: the node_level field is missing. Thankfully, the library provides a special facility just for this case - the extend function that lets you add a field to a JSON object:

instance EncodeJson UnitNode where
  encodeJson (UnitNode theRecord) = 
    extend ("node_level" /\ encodeJson "unit") (encodeJson theRecord)

And that's it. Now your UnitNode values will get serialized the way you want. Rinse and repeat for the other types.

Upvotes: 0

Related Questions