Let's Go Further شروع به کار › نقاط پایانی API و مسیریابی RESTful
قبلی · فهرست · بعدی
فصل ۲.۳.

نقاط پایانی API و مسیریابی RESTful

در چند بخش بعدی کتاب، API خودمان را به‌تدریج کامل می‌کنیم تا endpointها کم‌کم به این شکل دربیایند:

عملیات Handler الگوی URL متد
نمایش اطلاعات برنامه healthcheckHandler /v1/healthcheck GET
نمایش جزئیات همه فیلم‌ها listMoviesHandler /v1/movies GET
ایجاد یک فیلم جدید createMovieHandler /v1/movies POST
نمایش جزئیات یک فیلم مشخص showMovieHandler /v1/movies/:id GET
به‌روزرسانی جزئیات یک فیلم مشخص editMovieHandler /v1/movies/:id PUT
حذف یک فیلم مشخص deleteMovieHandler /v1/movies/:id DELETE

اگر قبلا APIهایی با endpointهای سبک REST ساخته باشید، جدول بالا احتمالا برایتان کاملا آشناست و توضیح زیادی لازم ندارد. اما اگر با این موضوع تازه آشنا می‌شوید، چند نکته مهم وجود دارد که باید به آن‌ها اشاره کنیم.

نکته اول این است که درخواست‌هایی با الگوی URL یکسان، بر اساس متد درخواست HTTP به handlerهای متفاوتی هدایت می‌شوند. هم از نظر امنیت و هم از نظر درستی معنایی، مهم است که برای عملیاتی که handler انجام می‌دهد از متد HTTP مناسب استفاده کنیم.

به طور خلاصه:

کاربرد متد
برای عملیاتی استفاده می‌شود که فقط اطلاعات را دریافت می‌کنند و وضعیت برنامه یا داده‌ای را تغییر نمی‌دهند. GET
برای عملیات non-idempotent که وضعیت را تغییر می‌دهند استفاده می‌شود. در زمینه یک REST API، معمولا از POST برای عملیاتی استفاده می‌شود که یک resource جدید ایجاد می‌کنند. POST
برای عملیات idempotent که وضعیت یک resource در یک URL مشخص را تغییر می‌دهند استفاده می‌شود. در زمینه یک REST API، معمولا از PUT برای عملیاتی استفاده می‌شود که یک resource موجود را جایگزین یا به‌روزرسانی می‌کنند. PUT
برای عملیاتی استفاده می‌شود که یک resource در یک URL مشخص را به صورت جزئی به‌روزرسانی می‌کنند. این عملیات می‌تواند idempotent یا non-idempotent باشد. PATCH
برای عملیاتی استفاده می‌شود که یک resource در یک URL مشخص را حذف می‌کنند. DELETE

نکته مهم دیگر این است که endpointهای API ما از clean URL استفاده می‌کنند و پارامترهای wildcard داخل مسیر URL قرار می‌گیرند. بنابراین، برای مثال، برای دریافت جزئیات یک فیلم مشخص، کلاینت به جای اینکه شناسه فیلم را به صورت پارامتر query string مثل GET /v1/movies?id=1 اضافه کند، درخواستی مثل GET /v1/movies/1 ارسال می‌کند.

انتخاب router

در این کتاب، به جای استفاده از http.ServeMux از کتابخانه استاندارد، از پکیج شخص ثالث محبوب httprouter به عنوان router برنامه استفاده می‌کنیم.

برای این کار دو دلیل داریم:

httprouter هر دو مورد بالا را پشتیبانی می‌کند و در کنار آن همه قابلیت‌های دیگری را که لازم داریم هم فراهم می‌کند. خود پکیج httprouter پایدار و خوب تست‌شده است و به عنوان یک مزیت اضافه، به لطف استفاده از radix tree برای تطبیق URL، بسیار سریع هم هست. اگر در حال ساخت یک JSON REST API عمومی هستید، httprouter انتخاب محکمی است و در مجموع، در حال حاضر گزینه بهتری نسبت به http.ServeMux باقی می‌ماند.

اگر همراه کتاب کدنویسی می‌کنید، لطفا از go get استفاده کنید تا آخرین نسخه v1.N.N از httprouter را به این شکل دانلود کنید:

$ go get github.com/julienschmidt/httprouter@v1
go: downloading github.com/julienschmidt/httprouter v1.3.0
go get: added github.com/julienschmidt/httprouter v1.3.0

برای نمایش نحوه کار httprouter، کار را با اضافه کردن دو endpoint برای ایجاد یک فیلم جدید و نمایش جزئیات یک فیلم مشخص به codebase خود شروع می‌کنیم. تا پایان این فصل، endpointهای API ما به این شکل خواهند بود:

عملیات Handler الگوی URL متد
نمایش اطلاعات برنامه healthcheckHandler /v1/healthcheck GET
ایجاد یک فیلم جدید createMovieHandler /v1/movies POST
نمایش جزئیات یک فیلم مشخص showMovieHandler /v1/movies/:id GET

کپسوله‌سازی routeهای API

برای اینکه با بزرگ‌تر شدن API تابع main() شلوغ نشود، همه قوانین routing را در یک فایل جدید به نام cmd/api/routes.go کپسوله می‌کنیم.

اگر همراه کتاب پیش می‌روید، این فایل جدید را ایجاد کنید و کد زیر را به آن اضافه کنید:

$ touch cmd/api/routes.go
File: cmd/api/routes.go
package main

import (
    "net/http"

    "github.com/julienschmidt/httprouter"
)

func (app *application) routes() http.Handler {
    // Initialize a new httprouter router instance.
    router := httprouter.New()

    // Register the relevant methods, URL patterns and handler functions for our
    // endpoints using the HandlerFunc() method. Note that http.MethodGet and 
    // http.MethodPost are constants which equate to the strings "GET" and "POST" 
    // respectively.
    router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
    router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler)
    router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler)

    // Return the httprouter instance.
    return router
}

کپسوله کردن قوانین routing به این شکل چند مزیت دارد. مزیت اول این است که تابع main() ما را تمیز نگه می‌دارد و مطمئن می‌شود همه routeها در یک جای واحد تعریف شده‌اند. مزیت بزرگ دیگر، که در کتاب اول Let’s Go آن را نشان دادیم، این است که حالا می‌توانیم در هر کد تستی به‌راحتی با initialize کردن یک نمونه application و فراخوانی متد routes() روی آن، به router دسترسی داشته باشیم.

کار بعدی این است که تابع main() را به‌روزرسانی کنیم تا تعریف http.ServeMux را حذف کند و به جای آن، نمونه httprouter برگردانده‌شده توسط app.routes() را به عنوان handler سرور استفاده کند. به این شکل:

File: cmd/api/main.go
package main

...

func main() {
    var cfg config

    flag.IntVar(&cfg.port, "port", 4000, "API server port")
    flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)")
    flag.Parse()

    logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

    app := &application{
        config: cfg,
        logger: logger,
    }

    // Use the httprouter instance returned by app.routes() as the server handler.
    srv := &http.Server{
        Addr:         fmt.Sprintf(":%d", cfg.port),
        Handler:      app.routes(),
        IdleTimeout:  time.Minute,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        ErrorLog:     slog.NewLogLogger(logger.Handler(), slog.LevelError),
    }

    logger.Info("starting server", "addr", srv.Addr, "env", cfg.env)
    err := srv.ListenAndServe()
    logger.Error(err.Error())
    os.Exit(1)
}

اضافه کردن handler functionهای جدید

حالا که قوانین routing تنظیم شده‌اند، باید متدهای createMovieHandler و showMovieHandler را برای endpointهای جدید بسازیم. showMovieHandler در اینجا به‌خصوص جالب است، چون می‌خواهیم در آن پارامتر شناسه فیلم را از URL استخراج کنیم و در پاسخ HTTP استفاده کنیم.

یک فایل جدید به نام cmd/api/movies.go ایجاد کنید تا این دو handler جدید را در خود نگه دارد:

$ touch cmd/api/movies.go

و سپس کد زیر را به آن اضافه کنید:

File: cmd/api/movies.go
package main

import (
    "fmt"
    "net/http"
    "strconv" 

    "github.com/julienschmidt/httprouter" 
)

// Add a createMovieHandler for the "POST /v1/movies" endpoint. For now we simply 
// return a plain-text placeholder response.
func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "create a new movie")
}

// Add a showMovieHandler for the "GET /v1/movies/:id" endpoint. For now, we retrieve 
// the interpolated "id" parameter from the current URL and include it in a placeholder 
// response.
func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) {
    // When httprouter is parsing a request, any interpolated URL parameters will be
    // stored in the request context. We can use the ParamsFromContext() function to
    // retrieve a slice containing these parameter names and values.
    params := httprouter.ParamsFromContext(r.Context())

    // We can then use the ByName() method to get the value of the "id" parameter from 
    // the slice. In our project all movies will have a unique positive integer ID, but 
    // the value returned by ByName() is always a string. So we try to convert it to a 
    // base 10 integer (with a bit size of 64). If the parameter couldn't be converted, 
    // or is less than 1, we know the ID is invalid so we use the http.NotFound() 
    // function to return a 404 Not Found response.
    id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
    if err != nil || id < 1 {
        http.NotFound(w, r)
        return
    }

    // Otherwise, interpolate the movie ID in a placeholder response.
    fmt.Fprintf(w, "show the details of movie %d\n", id)
}

و با این کار، حالا آماده‌ایم آن را امتحان کنیم.

برنامه API را دوباره راه‌اندازی کنید...

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development

سپس در حالی که سرور در حال اجراست، یک پنجره ترمینال دوم باز کنید و با استفاده از curl چند درخواست به endpointهای مختلف بفرستید. اگر همه چیز درست تنظیم شده باشد، پاسخ‌هایی شبیه این خواهید دید:

$ curl localhost:4000/v1/healthcheck
status: available
environment: development
version: 1.0.0

$ curl -X POST localhost:4000/v1/movies
create a new movie

$ curl localhost:4000/v1/movies/123
show the details of movie 123

دقت کنید که در مثال آخر، مقدار پارامتر id فیلم، یعنی 123، با موفقیت از URL خوانده شده و در پاسخ قرار گرفته است.

شاید بخواهید چند درخواست هم به یک URL مشخص با استفاده از یک متد HTTP پشتیبانی‌نشده بفرستید. برای مثال، بیایید یک درخواست POST به /v1/healthcheck ارسال کنیم:

$ curl -i -X POST localhost:4000/v1/healthcheck
HTTP/1.1 405 Method Not Allowed
Allow: GET, OPTIONS
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Tue, 06 Apr 2021 06:59:04 GMT
Content-Length: 19

Method Not Allowed

این خیلی خوب به نظر می‌رسد. پکیج httprouter به صورت خودکار یک پاسخ 405 Method Not Allowed برای ما ارسال کرده است، همراه با یک header به نام Allow که متدهای HTTP پشتیبانی‌شده برای آن endpoint را فهرست می‌کند.

به همین شکل، می‌توانید یک درخواست OPTIONS به یک URL مشخص بفرستید و httprouter پاسخی همراه با header مربوط به Allow برمی‌گرداند که متدهای HTTP پشتیبانی‌شده را مشخص می‌کند. مثل این:

$ curl -i -X OPTIONS localhost:4000/v1/healthcheck
HTTP/1.1 200 OK
Allow: GET, OPTIONS
Date: Tue, 06 Apr 2021 07:01:29 GMT
Content-Length: 0

در نهایت، شاید بخواهید یک درخواست به endpoint مربوط به GET /v1/movies/:id با یک عدد منفی یا مقدار غیرعددی برای id در URL ارسال کنید. نتیجه باید یک پاسخ 404 Not Found شبیه این باشد:

$ curl -i localhost:4000/v1/movies/abc
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Tue, 06 Apr 2021 07:02:01 GMT
Content-Length: 19

404 page not found

ایجاد helper برای خواندن پارامترهای ID

کدی که پارامتر id را از URLای مثل /v1/movies/:id استخراج می‌کند، چیزی است که بارها در برنامه به آن نیاز خواهیم داشت؛ پس منطق آن را در یک helper method کوچک و قابل استفاده مجدد abstract می‌کنیم.

یک فایل جدید به نام cmd/api/helpers.go ایجاد کنید:

$ touch cmd/api/helpers.go

و یک متد جدید به نام readIDParam() به struct مربوط به application اضافه کنید، به این شکل:

File: cmd/api/helpers.go
package main

import (
    "errors"
    "net/http"
    "strconv"

    "github.com/julienschmidt/httprouter"
)

// Retrieve the "id" URL parameter from the current request context, then convert it to
// an integer and return it. If the operation isn't successful, return 0 and an error. 
func (app *application) readIDParam(r *http.Request) (int64, error) {
    params := httprouter.ParamsFromContext(r.Context())

    id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
    if err != nil || id < 1 {
        return 0, errors.New("invalid id parameter")
    }

    return id, nil
}

با وجود این helper method، حالا کد داخل showMovieHandler می‌تواند بسیار ساده‌تر شود:

File: cmd/api/movies.go
package main

import (
    "fmt"
    "net/http"
)

...

func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) {
    id, err := app.readIDParam(r)
    if err != nil {
        http.NotFound(w, r)
        return
    }

    fmt.Fprintf(w, "show the details of movie %d\n", id)
}

اطلاعات تکمیلی

Routeهای متداخل

مهم است بدانید که httprouter اجازه routeهای متداخل را نمی‌دهد؛ routeهایی که ممکن است با یک درخواست یکسان match شوند. بنابراین، برای مثال، نمی‌توانید همزمان routeای مثل GET /foo/new و route دیگری با یک بخش پارامتری که با آن تداخل دارد، مثل GET /foo/:id، ثبت کنید.

اگر برای endpointهای API خود از یک ساختار REST استاندارد استفاده می‌کنید، همان‌طور که در این کتاب انجام می‌دهیم، این محدودیت بعید است مشکل زیادی برایتان ایجاد کند.

در واقع، می‌توان گفت این یک نکته مثبت است. چون routeهای متداخل مجاز نیستند، قانون‌های اولویت‌بندی routing وجود ندارند که لازم باشد نگرانشان باشید و این موضوع خطر bug و رفتار ناخواسته در برنامه را کاهش می‌دهد.

اما اگر واقعا لازم دارید routeهای متداخل را پشتیبانی کنید، مثلا شاید برای سازگاری با نسخه‌های قبلی لازم باشد endpointهای یک API موجود را دقیقا بازتولید کنید، پیشنهاد می‌کنم به جای آن نگاهی به chi، Gorilla mux یا flow بیندازید. همه این‌ها routerهای خوبی هستند که routeهای متداخل را مجاز می‌دانند.

سفارشی‌سازی رفتار httprouter

پکیج httprouter چند گزینه پیکربندی فراهم می‌کند که می‌توانید با آن‌ها رفتار برنامه خود را بیشتر سفارشی کنید؛ از جمله فعال کردن trailing slash redirects و فعال کردن automatic URL path cleaning.

اطلاعات بیشتر درباره تنظیمات موجود را می‌توانید اینجا پیدا کنید.