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 تتيح لك تحديد كيفية تمثيل 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: مستخدمون، وطلبات سحب، وتعليقات، ويكون backend جاهزًا بسرعة. وهذا مثال على الحمولة التي يقدّمها الخادم عندما تطلب منه قائمة التعليقات على طلب سحب:

[
  {
    "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

بعيدًا عن القصة الطريفة، فإن متطلبات هذا التمرين مأخوذة مباشرة من نقاط النهاية الخاصة بواجهة GitHub REST API لتعليقات مراجعة طلبات السحب. حُذفت بعض الحقول من مخططات JSON من أجل التبسيط، لكن هذا أقرب ما يكون إلى تطبيقات العالم الحقيقي.

1. المعرِّف، من فضلك!

عينك الثاقبة لاحظت أن التعليقات والمستخدمين كلاهما لديه حقل id، وهو مكان مثالي للبدء.

عرّف decodeId بحيث يفكّ ترميز كائن JSON يحتوي على id من نوع عدد صحيح.

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 لمستخدم. احرص على استخدام 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_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 بحيث يفكّ ترميز إما "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 تمارين، وإرشاد بشري حقيقي، وكل ذلك مجانًا.