Let's Go Further parse کردن درخواست‌های JSON › decode سفارشی JSON
قبلی · فهرست مطالب · بعدی
فصل ۴.۴.

decode سفارشی JSON

قبلا در این کتاب مقداری رفتار encoding سفارشی JSON به API اضافه کردیم تا اطلاعات runtime فیلم در پاسخ‌های JSON با فرمت "<runtime> mins" نمایش داده شود.

در این فصل از سمت دیگر به همین موضوع نگاه می‌کنیم و application را به‌روزرسانی می‌کنیم تا createMovieHandler اطلاعات runtime را با همین فرمت بپذیرد.

اگر همین حالا تلاش کنید requestای با runtime فیلم در این فرمت بفرستید، یک پاسخ 400 Bad Request می‌گیرید؛ چون امکان decode کردن یک JSON string داخل type نوع int32 وجود ندارد. به این شکل:

$ curl -d '{"title": "Moana", "runtime": "107 mins"}' localhost:4000/v1/movies
{
    "error": "body contains incorrect JSON type for \"runtime\""
}

برای اینکه این کار عملی شود، باید فرایند decoding را intercept کنیم و JSON string با فرمت "<runtime> mins" را خودمان به int32 تبدیل کنیم.

پس چطور می‌توانیم این کار را انجام دهیم؟

interface مربوط به json.Unmarshaler

نکته کلیدی اینجا شناخت interface مربوط به json.Unmarshaler در Go است، که به این شکل است:

type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}

وقتی Go در حال decode کردن JSON است، بررسی می‌کند که آیا type مقصد interface مربوط به json.Unmarshaler را satisfy می‌کند یا نه. اگر این interface را satisfy کند، Go متد UnmarshalJSON() آن را صدا می‌زند تا مشخص کند JSON ارائه‌شده چطور باید داخل type هدف decode شود. این اساسا برعکس interface مربوط به json.Marshaler است که قبلا برای سفارشی کردن رفتار encoding به JSON استفاده کردیم.

بیایید ببینیم در عمل چطور از این استفاده می‌شود.

اولین کاری که باید انجام دهیم این است که createMovieHandler را به‌روزرسانی کنیم تا struct مربوط به input به جای یک int32 معمولی، از type سفارشی Runtime ما استفاده کند. یادتان هست که type مربوط به Runtime همچنان underlying type برابر با int32 دارد، اما با تبدیل کردن آن به یک custom type آزادیم که یک متد UnmarshalJSON() روی آن پیاده‌سازی کنیم.

handler را به این شکل به‌روزرسانی کنید:

File: cmd/api/movies.go
package main

...

func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title   string       `json:"title"`
        Year    int32        `json:"year"`
        Runtime data.Runtime `json:"runtime"` // Make this field a data.Runtime type.
        Genres  []string     `json:"genres"`
    }

    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    fmt.Fprintf(w, "%+v\n", input)
}

...

بعد سراغ فایل internal/data/runtime.go می‌رویم و یک متد UnmarshalJSON() به type مربوط به Runtime اضافه می‌کنیم. در این متد باید JSON string با فرمت "<runtime> mins" را parse کنیم، عدد runtime را به int32 تبدیل کنیم، و بعد آن را به خود مقدار Runtime assign کنیم.

این کار در واقع کمی ظریف است و چند جزئیات مهم دارد، پس احتمالا بهتر است وارد کد شویم و هم‌زمان با commentها توضیحش بدهیم.

File: internal/data/runtime.go
package data

import (
    "errors" // New import
    "fmt"
    "strconv"
    "strings" // New import
)

// Define an error that our UnmarshalJSON() method can return if we're unable to parse
// or convert the JSON string successfully.
var ErrInvalidRuntimeFormat = errors.New("invalid runtime format")

type Runtime int32

...

// Implement a UnmarshalJSON() method on the Runtime type so that it satisfies the
// json.Unmarshaler interface. IMPORTANT: Because UnmarshalJSON() needs to modify the
// receiver (our Runtime type), we must use a pointer receiver for this to work 
// correctly. Otherwise, we will only be modifying a copy (which is then discarded when 
// this method returns).
func (r *Runtime) UnmarshalJSON(jsonValue []byte) error {
    // We expect that the incoming JSON value will be a string in the format 
    // "<runtime> mins", and the first thing we need to do is remove the surrounding 
    // double quotes from this string. If we can't unquote it, then we return the 
    // ErrInvalidRuntimeFormat error.
    unquotedJSONValue, err := strconv.Unquote(string(jsonValue))
    if err != nil {
        return ErrInvalidRuntimeFormat
    }

    // Split the string to isolate the part containing the number. 
    parts := strings.Split(unquotedJSONValue, " ")

    // Sanity check the parts of the string to make sure it was in the expected format. 
    // If it isn't, we return the ErrInvalidRuntimeFormat error again.
    if len(parts) != 2 || parts[1] != "mins" {
        return ErrInvalidRuntimeFormat
    }

    // Otherwise, parse the string containing the number into an int32. Again, if this
    // fails return the ErrInvalidRuntimeFormat error.
    i, err := strconv.ParseInt(parts[0], 10, 32)
    if err != nil {
        return ErrInvalidRuntimeFormat
    }

    // Convert the int32 to a Runtime type and assign this to the receiver. Note that we
    // use the * operator to dereference the receiver (which is a pointer to a Runtime 
    // type) in order to set the underlying value of the pointer.
    *r = Runtime(i)

    return nil
}

وقتی این کار انجام شد، application را دوباره راه‌اندازی کنید و بعد یک request با مقدار runtime در فرمت جدید داخل JSON بفرستید. باید ببینید request با موفقیت کامل می‌شود، عدد از string استخراج می‌شود و به field مربوط به Runtime در struct مربوط به input assign می‌شود. به این شکل:

$ curl -d '{"title": "Moana", "runtime": "107 mins"}' localhost:4000/v1/movies
{Title:Moana Year:0 Runtime:107 Genres:[]}

در مقابل، اگر request را با یک JSON number یا هر format دیگری بفرستید، حالا باید یک پاسخ خطا دریافت کنید که شامل پیام variable مربوط به ErrInvalidRuntimeFormat است، شبیه این:

$ curl -d '{"title": "Moana", "runtime": 107}' localhost:4000/v1/movies
{
        "error": "invalid runtime format"
}

$ curl -d '{"title": "Moana", "runtime": "107 minutes"}' localhost:4000/v1/movies
{
        "error": "invalid runtime format"
}