GitHup API

GitHup API

Навчальна вправа

Вступ

JSON

JSON

JSON (JavaScript Object Notation) - це читабельний для людини формат даних і файлів, який часто використовують для обміну даними, зокрема у вебзастосунках. Тож не дивно, що він посідає особливе місце в мові Elm.

Elm надає основні модулі Json.Decode та Json.Encode для розбору й запису даних JSON. Декодери JSON - це спосіб оголосити, який тип даних очікується і на яку структуру даних мають відобразитися ці дані.

Імпортуймо кілька псевдонімів і типів із модулів:

import Json.Decode as Decode exposing (Decoder, Error)
import Json.Encode as Encode exposing (Value)

Використання Decode.decodeString : Decoder a -> String -> Result Error a з декодером і рядком тексту (англ. string), що містить JSON, або успішно завершиться, якщо рядок відповідає специфікації декодера, або зазнає невдачі в іншому разі. Немає способу зазирнути в необроблені дані JSON, коли ми визначаємо декодер, що може здатися незвичним, якщо ми звикли до імперативних мов.

Основні декодери типів даних

JSON має невелику кількість основних типів даних:

  • Рядок тексту: кодується подвійними лапками "hello world"
  • Булеве значення (англ. Boolean): або true, або false
  • Число: наприклад 34 або 0.12
  • Null: відсутність даних null

Кожен із них має відповідну функцію в Decode:

Decode.decodeString Decode.string """\"hello\""""
    --> Ok "hello"

Decode.decodeString Decode.string """null"""
    --> Err ...

Decode.decodeString Decode.bool """true"""
    --> Ok True

Decode.decodeString Decode.bool """0"""
    --> Err ...

Decode.decodeString Decode.int """12"""
    --> Ok 12

Decode.decodeString Decode.float """3.14"""
    --> Ok 3.14

Decode.decodeString (Decode.null AnyValue) """null"""
    --> Ok AnyValue

Decode.decodeString (Decode.null AnyValue) """true"""
    --> Err ...

Зауважмо, що Decode.null дає нам змогу вирішити, як змоделювати null у нашій програмі, вимагаючи довільне значення як аргумент (можливо, (), Nothing або будь-що доречне для нашої програми).

Поєднання декодерів

JSON також визначає дві структури даних, щоб збирати основні типи даних:

  • Масив: набір інших типів даних [0, null, "none", []]
  • Обʼєкт: набір пар ключ-значення {"id": 1, "is_admin": false, "more_info": {}}

Decode також пропонує функції для декодування цих структур:

Decode.decodeString (Decode.list Decode.int) """[1, 2, 3]"""
    --> Ok [1, 2, 3]

Decode.decodeString (Decode.list Decode.int) """[1, 2, 3, "not an int"]"""
    --> Err ...

Decode.decodeString (Decode.dict Decode.int) """{ "key1": 17, "key2": 71 }"""
    --> Ok (Dict.fromList [("key1", 17), ("key2", 71)])

Decode.decodeString (Decode.dict Decode.int) """{ "key1": 17, "key2": "seventy-one" }"""
    --> Err ...

Ці функції Decode.list : Decoder a -> Decoder (List a) і Decode.dict : Decoder a -> Decoder (Dict String a) приймають декодер як аргумент, який має декодувати кожен окремий елемент цієї структури даних. Якщо декодер-аргумент не зможе декодувати якийсь елемент масиву чи обʼєкта, весь декодер зазнає невдачі, і це поширений прийом.

Поєднання простих декодерів у складніші - це основна ідея цієї техніки. Ми познайомимося з кількома іншими функціями, які дають змогу будувати як завгодно складні декодери даних реального світу, на прикладі.

GeoJSON

Щоб потренуватися поєднувати базові декодери для розбору складних даних JSON, збудуймо декодери для розбору (підмножини) даних GeoJSON. GeoJSON - це спеціалізований формат даних на основі JSON, який використовують для представлення географічних обʼєктів, як-от місця, шляхи чи регіони, заданих за допомогою двовимірних чи тривимірних географічних координат разом з іншими властивостями.

Обʼєкт Geometry

Обʼєкт Geometry у GeoJSON - це обʼєкт, який містить поле type зі значенням-рядком тексту (можливі 7 значень типу) і поле coordinates з географічними координатами (зібраними в структуру, що залежить від типу). Наприклад,

{
  "type": "Point",
  "coordinates": [127.831, 26.461]
}

або

{
  "type": "LineString",
  "coordinates": [
    [127.831, 26.461],
    [127.829, 26.465],
    [127.829, 26.469],
    [127.83, 26.47]
  ]
}

Обʼєкт Geometry можна представити в Elm таким типом

type Geometry
  = Point (List Float)
  | LineString (List (List Float))
  | ...

Щоб дістати значення певного поля, ми можемо використати Decode.field : String -> Decoder a -> Decoder a, який декодує значення конкретного ключа обʼєкта.

decodePointCoordinates : Decoder (List Float)
decodePointCoordinates =
    Decode.field "coordinates" (Decode.list Decode.float)

decodeLineStringCoordinates : Decoder (List (List Float))
decodeLineStringCoordinates =
    Decode.field "coordinates" (Decode.list (Decode.list Decode.float))

Щоб перетворити значення, успішно розібране декодером, ми можемо використати Decode.map : (a -> value) -> Decoder a -> Decoder value або будь-який з його родичів, від Decode.map2 (a -> b -> value) -> Decoder a -> Decoder b -> Decoder value до Decode.map8.

У цьому випадку ми хочемо вирішити, який декодер координат використати, залежно від значення поля type, і для цього ідеально підходить Decode.andThen : (a -> Decoder b) -> Decoder a -> Decoder b та його друзі Decode.succeed : a -> Decoder a і Decode.fail : String -> Decoder a.

decodeGeometry : Decoder Geometry
decodeGeometry =
    Decode.field "type" Decode.string
        |> Decode.andThen
            (\value ->
                case value of
                    "Point" ->
                        decodePointCoordinates |> Decode.map Point

                    "LineString" ->
                        decodeLineStringCoordinates |> Decode.map LineString

                    _ ->
                        Decode.fail "Geometry not implemented yet, or invalid type value"
            )

Зауважмо, що тут ми могли б використати Decode.andThen, щоб перевірити координати (переконатися, що координати йдуть парами чи трійками, і що значення координат мають географічний сенс).

Decode.decodeString decodeGeometry """{"type": "Point", "coordinates": [127.831, 26.461]}"""
    --> Ok (Point [127.831, 26.461])

Decode.decodeString decodeGeometry """{"type": "LineString", "coordinates": [127.831, 26.461]}"""
    --> Err ...
Обʼєкт Feature

Обʼєкт Feature у GeoJSON представляє щось просторово обмежене: він має поле type зі значенням "Feature", поле geometry з обʼєктом Geometry або null, поле properties з довільним обʼєктом JSON або null, і необовʼязкове поле id зі значенням-рядком тексту або числом JSON.

{
  "type": "Feature",
  "geometry": null,
  "properties": null
}

або

{
  "type": "Feature",
  "id": "0157",
  "geometry": {
    "type": "Point",
    "coordinates": [127.831, 26.461]
  },
  "properties": {
    "country": "Japan"
  }
}

Спершу розберімо поле id. Якщо воно існує, то має вміщати рядки тексту або числа, тож ми можемо або ввести власний тип для цього значення, або завжди зберігати його як рядок тексту.

Щоб вибрати між двома можливими декодерами, ми можемо використати Decode.oneOf : List (Decoder a) -> Decoder a.

decodeFeatureId : Decoder String
decodeFeatureId =
    Decode.oneOf
        [ Decode.string
        , Decode.int |> Decode.map String.fromInt
        , Decode.float |> Decode.map String.fromFloat
        ]
        |> Decode.field "id"

Decode.decodeString decodeFeatureId """{"id": "seventeen"}"""
    --> Ok "seventeen"

Decode.decodeString decodeFeatureId """{"id": 17}"""
    --> Ok "17"

Decode.decodeString decodeFeatureId """{"type": "Point"}"""
    --> Er ...

Щоб виразити, що поле необовʼязкове, ми могли б скористатися тим самим прийомом

decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
    Decode.oneOf
        [ decodeFeatureId |> Decode.map Just
        , Decode.succeed Nothing
        ]

Але натомість ми могли б використати Decode.maybe : Decoder a -> Decoder (Maybe a)

decodeMaybeFeatureId : Decoder (Maybe String)
decodeMaybeFeatureId =
    Decode.maybe decodeFeatureId

Decode.decodeString decodeMaybeFeatureId """{"id": 17}"""
    --> Ok (Just "17")

Decode.decodeString decodeMaybeFeatureId """{"type": "Point"}"""
    --> Ok Nothing

Зауважмо, що decodeMaybeFeatureId (або будь-який декодер, обгорнутий у Decode.maybe) завжди буде успішним, бо завжди може повернутися до Ok Nothing, хоча декодер, який його використовує, все одно може зазнати невдачі

Decode.decodeString (Decode.list decodeMaybeFeatureId) """{"id": 17}"""
    --> Err ...

Далі зосередьмося на полі geometry, яке може бути або обʼєктом Geometry, або null. Ми могли б знову використати Decode.oneOf, але є кращий варіант: Decode.nullable : Decoder a -> Decoder (Maybe a).

decodeFeatureGeometry : Decoder (Maybe Geometry)
decodeFeatureGeometry =
    Decode.nullable decodeGeometry
        |> Decode.field "geometry"

Decode.decodeString decodeFeatureGeometry """{"geometry": {"type": "Point", "coordinates": [127.831, 26.461]}}"""
    --> Ok (Just (Point [127.831, 26.461]))

Decode.decodeString decodeFeatureGeometry """{"geometry": null}"""
    --> Ok Nothing

Decode.decodeString decodeFeatureGeometry """{"geometry": {}}"""
    --> Err ...

Decode.maybe і Decode.nullable мають однакову сигнатуру, але поводяться по-різному, бо результат OK Nothing у Decode.nullable може походити лише від конкретного значення JSON null.

Нарешті, поле properties може містити довільний обʼєкт JSON або null, і для цього підходить Decode.value : Decoder Value.

decodeProperties : Decoder (Maybe (Dict String Value))
decodeProperties =
    Decode.nullable (Decode.dict Decode.value)
        |> Decode.field "properties"


Decode.decodeString decodeProperties """{"properties": null}"""
    --> Ok Nothing

Decode.decodeString decodeProperties """{"properties": {"country": "Japan"}}"""
    --> Ok (Just (Dict.fromList [("country", <internals>)]))

Decode.decodeString decodeProperties """{"properties": 17}"""
    --> Err ...

Зауважмо, що Value - це непрозорий тип, а отже, ми не можемо легко зазирнути в його вміст. Щоб його використати, ми можемо або написати для нього декодер і запустити його за допомогою Decode.decodeValue : Decoder a -> Value -> Result Error a, або надіслати його напряму в JavaScript через порт.

Ми готові розбирати обʼєкти Feature:

type alias Feature =
    { id : Maybe String
    , geometry : Maybe Geometry
    , properties : Maybe (Dict String Value)
    }

decodeFeature : Decoder Feature
decodeFeature =
    Decode.field "type" Decode.string
        |> Decode.andThen
            (\value ->
                if value /= "Feature" then
                    Decode.fail "not a Feature"

                else
                    Decode.map3 Feature
                        decodeMaybeFeatureId
                        decodeFeatureGeometry
                        decodeProperties
            )

Decode.decodeString decodeFeature """{"type": "Feature", "geometry": null, "properties": null}"""
    --> Ok { geometry = Nothing, id = Nothing, properties = Nothing }

"""
{
  "type": "Feature",
  "id": "0157",
  "geometry": {
    "type": "Point",
    "coordinates": [127.831, 26.461]
  },
  "properties": {
    "country": "Japan"
  }
}
"""
  |> Decode.decodeString decodeFeature
    --> Ok { geometry = Just (Point [127.831,26.461]), id = Just "0157", properties = Just (Dict.fromList [("country",<internals>)]) }

Кодувальники JSON

Кодувальники дають змогу записувати коректний JSON зі значень Elm за допомогою функції Encode.encode : Int -> Value -> String. Перший аргумент задає розмір відступу в кінцевому результаті, а другий - значення JSON для запису.

Value можна або отримати з Decode.value, або створити за допомогою одного з кодувальників:

Encode.encode 0 (Encode.string "hello")
    --> "hello"

Encode.encode 0 (Encode.bool True)
    --> "true"

Encode.encode 0 (Encode.int 12)
    --> "12"

Encode.encode 0 (Encode.float 3.14)
    --> "3.14"

Encode.encode 0 Encode.null
    --> "null"

Encode.encode 0 (Encode.list Encode.int [1, 2, 3])
    --> "[1,2,3]"

Encode.encode 4 (Encode.list Encode.int [1, 2, 3])
    --> "[\n    1,\n    2,\n    3\n]"

Encode.encode 0 (Encode.dict String.toLower Encode.int (Dict.fromList [("KEY1", 17), ("KEY2", 71)]))
    --> "{\"key1\":17,\"key2\":71}"

Encode.encode 4 (Encode.dict String.toLower Encode.int (Dict.fromList [("KEY1", 17), ("KEY2", 71)]))
    --> "{\n    \"key1\": 17,\n    \"key2\": 71\n}"

а також за допомогою Encode.object : List ( String, Value ) -> Value

Encode.object
    [ ( "key1", Encode.int 17 )
    , ( "key2", Encode.int 71 )
    ]
    |> Encode.encode 0
    --> "{\"key1\":17,\"key2\":71}"

Збудуймо кодувальники для декодерів GeoJSON, визначених раніше

encodeGeometry : Geometry -> Value
encodeGeometry geometry =
    case geometry of
        Point coord ->
            Encode.object
                [ ( "type", Encode.string "Point" )
                , ( "coordinates", Encode.list Encode.float coord )
                ]

        LineString coord ->
            Encode.object
                [ ( "type", Encode.string "LineString" )
                , ( "coordinates", Encode.list (Encode.list Encode.float) coord )
                ]

encodeFeature : Feature -> Value
encodeFeature feature =
    let
        maybeId =
            case feature.id of
                Nothing ->
                    []

                Just id ->
                    [ ( "id", Encode.string id ) ]
    in
    Encode.object
        (maybeId
            ++ [ ( "type", Encode.string "Feature" )
               , ( "geometry"
                 , case feature.geometry of
                    Nothing ->
                        Encode.null

                    Just geometry ->
                        encodeGeometry geometry
                 )
               , ( "properties"
                 , case feature.properties of
                    Nothing ->
                        Encode.null

                    Just dict ->
                        Encode.dict identity identity dict
                 )
               ]
        )

Загалом, добра перевірка для пари декодер-кодувальник - це переконатися, що elmValue |> encoder |> decoder == elmValue.

Вказівки

Чим більше ми дізнаємося про програмування, тим частіше починаємо думати, що це може бути більше, ніж просто миттєва мода. Можливо, тут навіть є ділові можливості: люди пишуть код, хочуть його зберегти, поділитися, працювати разом, а може, й отримати відгук.

Гаразд, нумо до справи, створімо сервіс, який спонукає людей програмувати: ну ж бо, ну ж бо, народе, починаймо користуватися git! Назвімо його... GitHup.

Ми беремося створювати REST JSON API: користувачі, pull request-и, коментарі, і backend швидко готовий. Ось приклад даних, які сервер віддає, коли ми запитуємо в нього список коментарів до pull request:

[
  {
    "id": 256,
    "pull_request_review_id": 42,
    "user": {
      "id": 101,
      "login": "octodog",
      "avatar_url": "https://githup.com/images/error/octodog_happy.gif",
      "site_admin": false
    },
    "body": "Great stuff!",
    "side": "RIGHT",
    "_links": {
      "self": {
        "href": "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1"
      },
      "html": {
        "href": "https://githup.com/octodog/Hello-World/pull/1#discussion-diff-1"
      },
      "pull_request": {
        "href": "https://api.githup.com/repos/octodog/Hello-World/pulls/1"
      }
    }
  },
  {
    "id": 11,
    "pull_request_review_id": null,
    "user": {
      "id": 2,
      "login": "hexacat",
      "name": "Alex Kate",
      "avatar_url": "https://githup.com/images/error/hexacat_happy.gif",
      "site_admin": true
    },
    "body": "Amazing stuff!",
    "side": "LEFT",
    "_links": {
      "self": {
        "href": "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1"
      }
    }
  }
]

Звісно, frontend буде зроблено на Elm, але спершу треба розібратися, що це за сумнозвісні декодери й енкодери.

Note

Жартівлива історія осторонь, вимоги цієї вправи взято безпосередньо з ендпоінтів REST API GitHub для коментарів до рецензій pull request. Частину полів прибрали зі схем JSON заради простоти, але ближче до реальних застосунків уже годі й уявити.

1. ID, будь ласка!

Наш пильний погляд помітив, що і коментарі, і користувачі мають поле id, ідеальне місце, щоб почати.

Визначмо decodeId так, щоб він декодував обʼєкт JSON із цілим числом у полі id.

Decode.decodeString decodeId """{id: 10}"""
    --> Ok 10

2. То як мене звати?

Ми помічаємо в прикладі вище, що не всі користувачі мають імʼя.

Визначмо decodeName так, щоб він декодував рядок тексту (англ. string) у полі name, якщо він там є. Немає імені? Не проблема, просто повертаємо Nothing. Цей декодер ніколи не повинен зазнавати невдачі.

Decode.decodeString decodeName """{"id": 10, "name": "Otto"}"""
    --> Ok (Just Otto)

Decode.decodeString decodeName """null"""
    --> Ok Nothing

3. Користувач? Я його ледве знаю!

Ми вже майже на півдорозі до декодування користувачів, тож доведімо справу до кінця.

Визначмо decodeUser так, щоб він декодував обʼєкт користувача у JSON. Обовʼязково використаймо decodeId і decodeName у decodeUser.

Decode.decodeString decodeUser
    """
    {
      "id": 101,
      "login": "octodog",
      "avatar_url": "https://githup.com/images/error/octodog_happy.gif",
      "site_admin": false
    }
    """
    --> Ok
    -->   { id = 101
    -->   , name = Nothing
    -->   , login = "octodog"
    -->   , avatarUrl = "https://githup.com/images/error/octodog_happy.gif
    -->   , siteAdmin = False
    -->   }

4. Я сильний незалежний коментар

Деякі коментарі в рецензії pull request є окремими, і тому не мають pull_request_review_id. Однак специфікація згадує, що в такому разі поле pull_request_review_id все одно має бути, але його значенням має бути null, тобто Nothing для бідних.

Визначмо decodePullRequestReviewId так, щоб він декодував ціле число в полі pull_request_review_id, якщо воно там є. Якщо цілого числа немає, має бути null; якщо немає null, декодер має зазнати невдачі.

Decode.decodeString decodePullRequestReviewId """{"pull_request_review_id": 3}"""
    --> Ok (Just 3)

Decode.decodeString decodePullRequestReviewId """{"pull_request_review_id": null}"""
    --> Ok Nothing

Decode.decodeString decodePullRequestReviewId """{"id": 3}"""
    --> Err ...

5. Підробіток

Далі це цікаве поле side. У рецензії pull request ми можемо коментувати код, який видалили (з боку Left на екрані), або код, який додали (з боку Right на екрані).

Визначмо decodeSide так, щоб він декодував або "LEFT", або "RIGHT" у полі side і зіставляв їх із відповідним варіантом типу.

Decode.decodeString decodeSide """{"side": "LEFT"}"""
    --> Ok Left

Decode.decodeString decodeSide """{"side": "middle?"}"""
    --> Err ...

6. Пробудження посилань

Кожен коментар має набір посилань, який, схоже, не є однаковим для всіх коментарів.

Визначмо decodeLinks так, щоб він декодував набір URL у полі _links. Назви й кількість посилань можуть змінюватися, тож переконаймося, що ми це враховуємо. Самі посилання є обʼєктами JSON, які завжди мають поле href.

Decode.decodeString decodeLinks """
    {
      "_links": {
        "self": { "href": "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1" },
        "html": { "href": "https://githup.com/octodog/Hello-World/pull/1#discussion-diff-1" },
        "pull_request": { "href": "https://api.githup.com/repos/octodog/Hello-World/pulls/1" }
      }
    }
    """
    --> Ok
    -->   (Dict.fromList
    -->       [ ( "self", "https://api.githup.com/repos/octodog/Hello-World/pulls/comments/1" )
    -->       , ( "html", "https://githup.com/octodog/Hello-World/pull/1#discussion-diff-1" )
    -->       , ( "pull_request", "https://api.githup.com/repos/octodog/Hello-World/pulls/1" )
    -->       ]
    -->   )

7. Так, коментарі

Майже готово, доведімо справу до кінця.

Визначмо decodeComment і decodeComments так, щоб вони декодували відповідно один обʼєкт коментаря JSON або список коментарів. Обовʼязково використаймо в decodeComment усі функції, визначені досі, крім decodeName. Звісно, decodeComments теж має використовувати decodeComment.

8. Усе зводиться докупи

У всьому цьому бракує ще однієї можливості: надсилати новий коментар на сервер. Для цього потрібне протилежне до декодера: вміння перетворювати Comment з Elm на Value у JSON.

Визначмо encodeComment так, щоб він кодував будь-який коректний Comment. Порядок полів не має значення, аби лише енкодер виробляв значення, яке decodeComment може декодувати.

Редагувати через GitHub Посилання відкривається в новому вікні або вкладці
Elm Exercism

Час розпочати GitHup API?

Зареєструйтеся на Exercism, щоб вивчати й опановувати Elm, а також 28 концепцій110 вправ та справжнє наставництво від людей, і все це безкоштовно.