GitHup API

GitHup API

학습 연습 문제

소개

JSON

JSON

JSON(JavaScript Object Notation)은 사람이 읽을 수 있는 데이터 및 파일 형식으로, 특히 웹 애플리케이션에서 데이터를 교환할 때 흔히 사용해요. 그래서 JSON이 Elm 언어에서 특별한 위치를 차지하는 것은 놀라운 일이 아니에요.

Elm은 JSON 데이터를 파싱하고 작성하기 위한 핵심 모듈인 Json.Decode와 Json.Encode를 제공해요. 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

이들 각각은 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은 위치, 경로, 지역 같은 지리적 특징을 2D 또는 3D 지리 좌표와 기타 속성으로 표현하는 데 사용되는, JSON 기반의 특수 데이터 형식이에요.

Geometry 객체

GeoJSON Geometry 객체는 문자열 값을 가진 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 객체

GeoJSON Feature 객체는 공간적으로 경계가 있는 무언가를 나타내요. "Feature" 값을 가진 type 필드, Geometry 객체 또는 null을 담는 geometry 필드, 임의의 JSON 객체 또는 null을 담는 properties 필드, 그리고 JSON 문자열이나 숫자 값을 담는 선택적 id 필드를 가져요.

{
  "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은 시그니처가 같지만 동작은 달라요. 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로 실행하거나, 포트를 통해 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 인코더

인코더를 사용하면 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를 만들기 시작해요. 사용자, 풀 리퀘스트, 댓글까지, 백엔드는 순식간에 완성돼요. 서버에 풀 리퀘스트의 댓글 목록을 요청했을 때 돌려주는 페이로드의 예시는 다음과 같아요.

[
  {
    "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 엔드포인트에서 그대로 가져왔어요. 간단하게 하려고 JSON 스키마에서 일부 필드는 뺐지만, 실제 애플리케이션에 이만큼 가까운 건 없어요.

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_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 필드예요. 풀 리뷰에서는 삭제된 코드(화면의 Left 쪽)에도, 추가된 코드(화면의 Right 쪽)에도 댓글을 달 수 있어요.

decodeSide를 정의해서 side 필드의 "LEFT" 또는 "RIGHT"를 디코딩하고, 그에 해당하는 타입 변형과 맞출 수 있게 해봐요.

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

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

6. 링크의 각성

각 댓글에는 링크 모음이 있는데, 댓글마다 일정해 보이지는 않아요.

decodeLinks를 정의해서 _links 필드에 있는 URL 모음을 디코딩할 수 있게 해봐요. 링크의 이름과 개수는 달라질 수 있으니, 그걸 처리할 수 있어야 해요. 링크 자체는 항상 href 필드를 가진 JSON 객체예요.

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 댓글 객체 하나와 댓글 목록을 디코딩할 수 있게 해봐요. decodeName을 빼고 지금까지 정의한 모든 함수를 decodeComment에서 사용해야 해요. 물론 decodeComments도 decodeComment를 사용해야 하고요.

8. 모두 하나로 이어져요

아직 하나 빠진 기능이 있어요. 바로 서버에 새 댓글을 보내는 거예요. 그러려면 디코더의 반대 역할이 필요해요. Elm Comment를 JSON Value로 변환할 수 있어야 하죠.

encodeComment를 정의해서 유효한 Comment라면 무엇이든 인코딩할 수 있게 해봐요. 필드 순서는 상관없어요. 인코더가 decodeComment로 디코딩할 수 있는 값을 만들어 내기만 하면 돼요.

GitHub에서 편집 링크가 새 창이나 탭에서 열려요
Elm Exercism

GitHup API 문제를 시작해 볼 준비가 됐나요?

Exercism에 가입하고 Elm 트랙을 개념 28개연습 문제 110개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.