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

اعتبارسنجی ورودی JSON

در بسیاری از موارد، قبل از پردازش داده‌ای که از client می‌آید، می‌خواهید validation checkهای بیشتری روی آن انجام دهید تا مطمئن شوید با business ruleهای خاص شما سازگار است. در این فصل نشان می‌دهیم چطور این کار را در context یک JSON API انجام دهیم؛ با به‌روزرسانی createMovieHandler تا بررسی کند که:

اگر هر کدام از این checkها fail شود، می‌خواهیم یک پاسخ 422 Unprocessable Entity همراه با پیام‌های خطایی برای client بفرستیم که validation failureها را به‌روشنی توضیح می‌دهند.

ساخت پکیج validator

برای کمک به validation در سراسر این پروژه، یک پکیج کوچک internal/validator با چند helper type و function ساده و قابل استفاده مجدد می‌سازیم. اگر همراه کتاب کدنویسی می‌کنید، directory و فایل زیر را روی ماشین خودتان بسازید:

$ mkdir internal/validator
$ touch internal/validator/validator.go

بعد در این فایل جدید internal/validator/validator.go، کد زیر را اضافه کنید:

File: internal/validator/validator.go
package validator

import (
    "regexp"
    "slices"
)

// Declare a regular expression for sanity checking the format of email addresses (we'll
// use this later in the book). If you're interested, this regular expression pattern is
// taken from https://html.spec.whatwg.org/#valid-e-mail-address. Note: if you're 
// reading this in PDF or EPUB format and cannot see the full pattern, please see the
// note further down the page.
var (
    EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
)

// Define a new Validator type which contains a map of validation errors.
type Validator struct {
    Errors map[string]string
}

// New is a helper which creates a new Validator instance with an empty errors map.
func New() *Validator {
    return &Validator{Errors: make(map[string]string)}
}

// Valid returns true if the errors map doesn't contain any entries.
func (v *Validator) Valid() bool {
    return len(v.Errors) == 0
}

// AddError adds an error message to the map (so long as no entry already exists for
// the given key).
func (v *Validator) AddError(key, message string) {
    if _, exists := v.Errors[key]; !exists {
        v.Errors[key] = message
    }
}

// Check adds an error message to the map only if a validation check is not 'ok'.
func (v *Validator) Check(ok bool, key, message string) {
    if !ok {
        v.AddError(key, message)
    }
}

// Generic function which returns true if a specific value is in a list of permitted
// values.
func PermittedValue[T comparable](value T, permittedValues ...T) bool {
    return slices.Contains(permittedValues, value)
}

// Matches returns true if a string value matches a specific regexp pattern.
func Matches(value string, rx *regexp.Regexp) bool {
    return rx.MatchString(value)
}

// Generic function which returns true if all values in a slice are unique.
func Unique[T comparable](values []T) bool {
    uniqueValues := make(map[T]bool)

    for _, value := range values {
        uniqueValues[value] = true
    }

    return len(values) == len(uniqueValues)
}

خلاصه این کد:

در کد بالا یک type سفارشی Validator تعریف کرده‌ایم که شامل یک map از errorهاست. type مربوط به Validator یک متد Check() برای اضافه کردن شرطی errorها به map فراهم می‌کند، و یک متد Valid() دارد که برمی‌گرداند آیا errors map خالی است یا نه. همچنین functionهای PermittedValue()، Matches() و Unique() را اضافه کرده‌ایم تا در انجام بعضی validation checkهای مشخص کمکمان کنند.

از نظر مفهومی، این type یعنی Validator کاملا basic است، اما این چیز بدی نیست. همان‌طور که در طول کتاب می‌بینیم، در عمل به شکل غافلگیرکننده‌ای قدرتمند است و flexibility و کنترل زیادی روی validation checkها و نحوه انجامشان به ما می‌دهد.

اگر این کتاب را در format PDF یا EPUB می‌خوانید و نمی‌توانید pattern کامل regexp مربوط به EmailRX را در snippet بالا ببینید، اینجا همان pattern به چند خط شکسته شده است:

"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?
(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"

در کد شما، این pattern مربوط به regexp باید کاملا در یک خط و بدون whitespace باشد.

انجام validation checkها

خب، بیایید شروع کنیم و از type مربوط به Validator استفاده کنیم!

اولین کاری که باید انجام دهیم این است که فایل cmd/api/errors.go را به‌روزرسانی کنیم تا helper جدید failedValidationResponse() را شامل شود؛ helperای که یک 422 Unprocessable Entity و محتوای errors map از type جدید Validator را به عنوان JSON response body می‌نویسد.

File: cmd/api/errors.go
package main

...

// Note that the errors parameter here has the type map[string]string, which is exactly  
// the same as the errors map contained in our Validator type.
func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) {
    app.errorResponse(w, r, http.StatusUnprocessableEntity, errors)
}

بعد از انجام این کار، به createMovieHandler برگردید و آن را به‌روزرسانی کنید تا validation checkهای لازم را روی struct مربوط به input انجام دهد. به این شکل:

File: cmd/api/movies.go
package main

import (
    "fmt"
    "net/http"
    "time"

    "greenlight.alexedwards.net/internal/data"
    "greenlight.alexedwards.net/internal/validator" // New import
)

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"`
        Genres  []string     `json:"genres"`
    }

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

    // Initialize a new Validator instance.
    v := validator.New()

    // Use the Check() method to execute our validation checks. This will add the 
    // provided key and error message to the errors map if the check does not evaluate 
    // to true. For example, in the first line here we "check that the title is not 
    // equal to the empty string". In the second, we "check that the length of the title
    // is less than or equal to 500 bytes" and so on.
    v.Check(input.Title != "", "title", "must be provided")
    v.Check(len(input.Title) <= 500, "title", "must not be more than 500 bytes long")

    v.Check(input.Year != 0, "year", "must be provided")
    v.Check(input.Year >= 1888, "year", "must be greater than 1888")
    v.Check(input.Year <= int32(time.Now().Year()), "year", "must not be in the future")

    v.Check(input.Runtime != 0, "runtime", "must be provided")
    v.Check(input.Runtime > 0, "runtime", "must be a positive integer")

    v.Check(input.Genres != nil, "genres", "must be provided")
    v.Check(len(input.Genres) >= 1, "genres", "must contain at least 1 genre")
    v.Check(len(input.Genres) <= 5, "genres", "must not contain more than 5 genres")
    // Note that we're using the Unique helper in the line below to check that all 
    // values in the input.Genres slice are unique.
    v.Check(validator.Unique(input.Genres), "genres", "must not contain duplicate values")

    // Use the Valid() method to see if any of the checks failed. If they did, then use
    // the failedValidationResponse() helper to send a response to the client, passing 
    // in the v.Errors map.
    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

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

...

با انجام این تغییرات، آماده‌ایم این را امتحان کنیم. API را دوباره راه‌اندازی کنید و بعد یک request شامل داده نامعتبر به endpoint مربوط به POST /v1/movies بفرستید. شبیه این:

$ BODY='{"title":"","year":1000,"runtime":"-123 mins","genres":["sci-fi","sci-fi"]}'
$ curl -i -d "$BODY" localhost:4000/v1/movies
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
Date: Wed, 07 Apr 2021 10:33:57 GMT
Content-Length: 180

{
    "error": {
        "genres": "must not contain duplicate values",
        "runtime": "must be a positive integer",
        "title": "must be provided",
        "year": "must be greater than 1888"
    }
}

خیلی خوب به نظر می‌رسد. validation checkهای ما کار می‌کنند و جلوی اجرای موفق request را می‌گیرند؛ و حتی بهتر اینکه client برای هر مشکل، یک پاسخ JSON خوش‌ساخت با پیام‌های خطای روشن و informative دریافت می‌کند.

اگر دوست دارید می‌توانید یک request معتبر هم بفرستید. باید ببینید که checkها با موفقیت pass می‌شوند و struct مربوط به input مثل قبل در پاسخ HTTP dump می‌شود:

$ BODY='{"title":"Moana","year":2016,"runtime":"107 mins","genres":["animation","adventure"]}'
$ curl -i -d "$BODY" localhost:4000/v1/movies
HTTP/1.1 200 OK
Date: Wed, 07 Apr 2021 10:35:40 GMT
Content-Length: 65
Content-Type: text/plain; charset=utf-8

{Title:Moana Year:2016 Runtime:107 Genres:[animation adventure]}

قابل استفاده مجدد کردن ruleهای validation

در پروژه‌های بزرگ، احتمالا می‌خواهید بعضی از validation checkهای مشابه را در چند جای مختلف reuse کنید. در مورد ما، برای مثال، بعدا وقتی یک client داده فیلم را ویرایش می‌کند، می‌خواهیم بسیاری از همین checkها را دوباره استفاده کنیم.

برای جلوگیری از duplication، می‌توانیم validation checkهای مربوط به یک فیلم را داخل یک function مستقل به نام ValidateMovie() جمع کنیم. از نظر تئوری این function تقریبا می‌تواند هر جایی در codebase ما زندگی کند؛ کنار handlerها در فایل cmd/api/movies.go، یا احتمالا در پکیج internal/validators. اما شخصا دوست دارم validation checkها را نزدیک domain type مرتبط، یعنی در پکیج internal/data نگه دارم.

اگر همراه کتاب جلو می‌روید، فایل internal/data/movies.go را دوباره باز کنید و یک function به نام ValidateMovie() شامل این checkها اضافه کنید، به این شکل:

File: internal/data/movies.go
package data

import (
    "time"

    "greenlight.alexedwards.net/internal/validator" // New import
)

type Movie struct {
    ID        int64     `json:"id"`
    CreatedAt time.Time `json:"-"`
    Title     string    `json:"title"`
    Year      int32     `json:"year,omitzero"`
    Runtime   Runtime   `json:"runtime,omitzero"`
    Genres    []string  `json:"genres,omitzero"`
    Version   int32     `json:"version"`
}

func ValidateMovie(v *validator.Validator, movie *Movie) {
    v.Check(movie.Title != "", "title", "must be provided")
    v.Check(len(movie.Title) <= 500, "title", "must not be more than 500 bytes long")

    v.Check(movie.Year != 0, "year", "must be provided")
    v.Check(movie.Year >= 1888, "year", "must be greater than 1888")
    v.Check(movie.Year <= int32(time.Now().Year()), "year", "must not be in the future")

    v.Check(movie.Runtime != 0, "runtime", "must be provided")
    v.Check(movie.Runtime > 0, "runtime", "must be a positive integer")

    v.Check(movie.Genres != nil, "genres", "must be provided")
    v.Check(len(movie.Genres) >= 1, "genres", "must contain at least 1 genre")
    v.Check(len(movie.Genres) <= 5, "genres", "must not contain more than 5 genres")
    v.Check(validator.Unique(movie.Genres), "genres", "must not contain duplicate values")
}

وقتی این کار انجام شد، باید به createMovieHandler برگردیم و آن را به‌روزرسانی کنیم تا یک struct جدید از نوع Movie initialize کند، داده را از struct مربوط به input داخل آن کپی کند، و بعد این validation function جدید را صدا بزند. به این شکل:

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"`
        Genres  []string     `json:"genres"`
    }

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

    // Copy the values from the input struct to a new Movie struct.
    movie := &data.Movie{
        Title:   input.Title,
        Year:    input.Year,
        Runtime: input.Runtime,
        Genres:  input.Genres,
    }

    // Initialize a new Validator.
    v := validator.New()

    // Call the ValidateMovie() function, and if any checks fail, return a response 
    // containing the errors.
    if data.ValidateMovie(v, movie); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

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

...

وقتی به این کد نگاه می‌کنید، ممکن است چند سوال در ذهنتان شکل بگیرد.

اول، شاید بپرسید چرا instance مربوط به Validator را در handler initialize می‌کنیم و آن را به function مربوط به ValidateMovie() پاس می‌دهیم، به جای اینکه داخل ValidateMovie() initialize شود و به عنوان return value برگردد.

دلیلش این است که با پیچیده‌تر شدن application، لازم می‌شود از handlerهایمان چند validation helper را صدا بزنیم، نه فقط یکی مثل همین مثال بالا. بنابراین initialize کردن Validator در handler و بعد پاس دادن آن به جاهای مختلف، flexibility بیشتری به ما می‌دهد.

همچنین شاید بپرسید چرا JSON request را داخل struct مربوط به input decode می‌کنیم و بعد داده را کپی می‌کنیم، به جای اینکه مستقیما داخل struct مربوط به Movie decode کنیم.

مشکل decode مستقیم داخل struct مربوط به Movie این است که client می‌تواند keyهای id و version را در JSON request خودش ارائه کند، و مقدارهای متناظر بدون هیچ خطایی داخل fieldهای ID و Version در struct مربوط به Movie decode می‌شوند؛ با اینکه نمی‌خواهیم این اتفاق بیفتد. می‌توانیم بعدا fieldهای لازم را در struct مربوط به Movie بررسی کنیم تا مطمئن شویم خالی هستند، اما این کمی hacky به نظر می‌رسد، و decode کردن داخل یک struct واسط، مثل کاری که در handler انجام می‌دهیم، رویکردی تمیزتر، ساده‌تر و robustتر است؛ هرچند کمی verboseتر.

خب، با این توضیحات، باید بتوانید application را دوباره start کنید و از دید client همه چیز مثل قبل کار کند. اگر یک request نامعتبر بفرستید، باید پاسخی شامل پیام‌های خطا شبیه این بگیرید:

$ BODY='{"title":"","year":1000,"runtime":"-123 mins","genres":["sci-fi","sci-fi"]}'
$ curl -i -d "$BODY" localhost:4000/v1/movies
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
Date: Wed, 07 Apr 2021 10:51:00 GMT
Content-Length: 180

{
    "error": {
        "genres": "must not contain duplicate values",
        "runtime": "must be a positive integer",
        "title": "must be provided",
        "year": "must be greater than 1888"
    }
}

با خیال راحت با این بخش بازی کنید و مقدارهای مختلفی در JSON بفرستید تا مطمئن شوید همه validation checkها همان‌طور که انتظار دارید کار می‌کنند.