學習軌道
/
Elm
Elm
/
練習
/
GitHup API
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,搭配一個解碼器和一段 JSON 字串,如果該字串符合解碼器的規格就會成功,否則就會失敗。定義解碼器時,你無法檢視原始的 JSON 資料,這對來自命令式語言的人來說可能會覺得不太習慣。

基本資料型態解碼器

JSON 只有少數幾種核心資料型態:

  • 字串:以雙引號編碼,例如"hello world"
  • 布林: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要求你傳入一個任意的值作為引數(可能是()、Nothing,或任何適合你程式的東西),讓你決定要在程式中如何表示null。

組合解碼器

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 為基礎的專門資料格式,用來表示地理特徵,例如位置、路徑或區域,並以 2D 或 3D 地理座標加上其他屬性來描述。

幾何物件

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]
  ]
}

幾何物件在 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 ...
特徵物件

GeoJSON 的特徵物件代表某個在空間上有範圍的東西:它有一個值為"Feature"的type欄位、一個可以是幾何物件或null的geometry欄位、一個可以是任意 JSON 物件或null的properties欄位,以及一個選用的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欄位,它可以是幾何物件,也可以是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的型別簽名相同,但行為不同,因為Decode.nullable的OK Nothing結果只會來自特定的 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來執行,或是透過 port 直接把它送到 JavaScript。

我們已經準備好要解析特徵物件了:

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 編碼器

編碼器讓你能用Encode.encode : Int -> Value -> String函式,把 Elm 的值寫成有效的 JSON。第一個引數指定最終結果的縮排量,第二個引數則是要寫入的 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、留言,後端很快就搞定了。 下面是當你向伺服器要求某個 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"
      }
    }
  }
]

當然,前端會用 Elm 來做,但首先你得搞清楚那些惡名昭彰的解碼器與編碼器到底在幹嘛。

Note

撇開這段搞笑的故事不談,這個練習的需求直接取自 GitHub 的 REST API 中,用來處理 Pull Request 審查留言的端點。 為了簡化起見,我們從 JSON schema 中移除了一些欄位,但這已經非常貼近真實世界的應用了。

1. 請給我 ID!

你銳利的目光已經發現,留言和使用者都有一個 id 欄位,這正是最好的起點。

定義 decodeId,讓它能解碼帶有整數 id 的 JSON 物件。

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

2. 我叫什麼名字去了?

你注意到上面的範例中,並非所有使用者都有名字。

定義 decodeName,讓它能在有 name 欄位時解碼出其中的字串。 沒有名字?沒問題,直接回傳 Nothing 就好。 這個解碼器永遠不該失敗。

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

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

3. 使用者?我跟他可不熟!

解碼使用者的部分我們大致完成一半了,把它做到底吧。

定義 decodeUser,讓它能解碼 JSON 的使用者物件。 請務必在 decodeUser 裡使用 decodeId 和 decodeName。

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,讓它能解碼 side 欄位中的 "LEFT" 或 "RIGHT",並對應到相應的型別變體。

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

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

6. 連結甦醒

每個留言都有一組連結,而這些連結在不同留言之間似乎並不一致。

定義 decodeLinks,讓它能解碼 _links 欄位中的一組 URL。 連結的名稱和數量都可能不同,請確保你能處理這種情況。 這些連結本身是 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. 全部串起來

這整件事還缺一個功能:能夠把新的留言送到伺服器。 為此,你需要做出解碼器的相反,也就是能把 Elm 的 Comment 轉換成 JSON 的 Value。

定義 encodeComment,讓它能編碼任何有效的 Comment。 欄位的順序無所謂,只要編碼器產生的值能被 decodeComment 解碼就行。

透過 GitHub 編輯 連結會在新視窗或分頁中開啟
Elm Exercism

準備好開始 GitHup API 了嗎?

註冊 Exercism,透過 28 個概念110 個練習 和真人引導來學習並精通 Elm,全部免費。