DEV Community

Cover image for Goravel request data: params, query and JSON body
Krishan
Krishan

Posted on Edited on Originally published at devchalk.com

Goravel request data: params, query and JSON body

Part 3 of the Goravel series. The original on DevChalk has the colour-matched code, the figures and the full diffs. Code for this part: krishankumar01/goravel-taskflow/tree/part-03.

Our routes answer, but they do not read anything the client sends. A client can send data in three places: the URL path, the query string and the request body. In this part we read all three.

Start from the Routing & Responses code:

git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-02
cp .env.example .env
go run . artisan key:generate
docker compose up -d
go install github.com/air-verse/air@latest
air
Enter fullscreen mode Exit fullscreen mode

Route parameters

To get one task, the client puts its id in the path, like /api/v1/tasks/5. The id is different on every request, so the route needs a placeholder instead of a fixed number. Add a Show method to TaskController:

app/http/controllers/task_controller.go

 // ... 16 lines not shown
 func (r *TaskController) Store(ctx http.Context) http.Response {
    return ctx.Response().String(200, "task created")
 }
+
+func (r *TaskController) Show(ctx http.Context) http.Response {
+   id := ctx.Request().RouteInt("id")
+
+   return ctx.Response().Json(200, map[string]any{
+       "id": id,
+   })
+}
Enter fullscreen mode Exit fullscreen mode

And its route in routes/api.go:

routes/api.go

 // ... 9 lines not shown
 func Api() {
    taskController := controllers.NewTaskController()

    facades.Route().Prefix("api/v1").Group(func(router route.Router) {
        router.Post("tasks", taskController.Store).Name("tasks.create")
+       router.Get("tasks/{id}", taskController.Show)
    })
 }
Enter fullscreen mode Exit fullscreen mode

The {id} in the path is a route parameter. It matches any value in that part of the path. In the handler, RouteInt reads that value by the same name, "id", and returns it as a number. The two names have to match.

Two other methods read the same value as other types:

  • Route("id") returns it as a string.
  • RouteInt64("id") returns it as an int64.

Add a request for it:

requests.http

 ### Create task
 POST http://localhost:3000/api/v1/tasks

+### Show task
+GET http://localhost:3000/api/v1/tasks/5
+
 ### Hello
 GET http://localhost:3000/hello
Enter fullscreen mode Exit fullscreen mode

Run it, or use curl:

$ curl http://localhost:3000/api/v1/tasks/5
{"id":5}
Enter fullscreen mode Exit fullscreen mode

If the id is not a number, RouteInt does not return an error. It returns 0:

$ curl http://localhost:3000/api/v1/tasks/abc
{"id":0}
Enter fullscreen mode Exit fullscreen mode

This API should reject that. We add that check later, with validation.

Query strings

To list tasks, the client may want only some of them, or one page at a time. Options like these go in the query string, the part of the URL after the ?, as in /api/v1/tasks?status=done&page=2. Each option is a key and a value, and & separates them. The code reads each one by its key, so the names have to match. Add an Index method for the list, and its route:

app/http/controllers/task_controller.go

 // ... 27 lines not shown
+
+func (r *TaskController) Index(ctx http.Context) http.Response {
+   status := ctx.Request().Query("status")
+   page := ctx.Request().QueryInt("page", 1)
+   filters := ctx.Request().QueryMap("filters")
+
+   return ctx.Response().Json(200, map[string]any{
+       "status":  status,
+       "page":    page,
+       "filters": filters,
+   })
+}
Enter fullscreen mode Exit fullscreen mode

routes/api.go

 // ... 9 lines not shown
 func Api() {
    taskController := controllers.NewTaskController()

    facades.Route().Prefix("api/v1").Group(func(router route.Router) {
        router.Post("tasks", taskController.Store).Name("tasks.create")
        router.Get("tasks/{id}", taskController.Show)
+       router.Get("tasks", taskController.Index)
    })
 }
Enter fullscreen mode Exit fullscreen mode
  • Query reads "status" as a string. If there is no status, you get an empty string.
  • QueryInt reads "page" as a number. Its second argument, 1, is the default when there is no page.
  • QueryMap collects the keys that start with "filters", like filters[priority], into one map.

Add a request with all three:

requests.http

 ### Create task
 POST http://localhost:3000/api/v1/tasks

 ### Show task
 GET http://localhost:3000/api/v1/tasks/5

+### List tasks
+GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
+
 ### Hello
 GET http://localhost:3000/hello
Enter fullscreen mode Exit fullscreen mode

With curl, add -g. Without it, curl reads the square brackets as a pattern and stops with curl: (3) bad range in URL:

$ curl -g "http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high"
Enter fullscreen mode Exit fullscreen mode

GET /api/v1/tasks?status=done&page=2&filters[priority]=high

{
  "filters": {
    "priority": "high"
  },
  "page": 2,
  "status": "done"
}
Enter fullscreen mode Exit fullscreen mode

The default is used only when page is missing. A page that is not a number gives 0, the same as RouteInt:

$ curl http://localhost:3000/api/v1/tasks
{"filters":{},"page":1,"status":""}
$ curl "http://localhost:3000/api/v1/tasks?page=abc"
{"filters":{},"page":0,"status":""}
Enter fullscreen mode Exit fullscreen mode

Other ways to read the query string:

Method Query string Returns
QueryInt64("big") ?big=9007199254740993 9007199254740993 as an int64
QueryBool("done") ?done=true true
QueryArray("tag") ?tag=api&tag=urgent ["api","urgent"]
Queries() ?tag=api&tag=urgent&page=2 {"page":"2","tag":"api,urgent"}
BindQuery(&query) ?status=done&page=2 a struct with Status and Page filled in
  • QueryBool also reads 1, yes and on as true. Anything else is false.
  • Queries joins a key that repeats into one string.
  • BindQuery fills a struct from the query string, using form tags.

The next section shows how binding into a struct works.

The request body

Creating a task sends more data: a title and a description. That goes in the request body, usually as JSON. Goravel can read the whole body into a struct. Change Store, the handler of POST /tasks:

app/http/controllers/task_controller.go

 package controllers

 import (
    "github.com/goravel/framework/contracts/http"
 )
+
+type CreateTaskRequest struct {
+   Title       string `form:"title" json:"title"`
+   Description string `form:"description" json:"description"`
+}

 // ... 10 lines not shown
 func (r *TaskController) Store(ctx http.Context) http.Response {
-   return ctx.Response().String(200, "task created")
+   var request CreateTaskRequest
+   if err := ctx.Request().Bind(&request); err != nil {
+       return ctx.Response().Json(400, map[string]any{
+           "error": err.Error(),
+       })
+   }
+
+   return ctx.Response().Json(200, request)
 }
 // ... 20 lines not shown
Enter fullscreen mode Exit fullscreen mode

CreateTaskRequest describes the body we expect. The tags tell Goravel which key goes into which field:

  • json:"title" reads the title key from a JSON body into Title.
  • form:"title" does the same for a form body. With both tags, one struct works for both.

Bind fills the struct from the body. We pass &request, the address of the struct, so that Bind can write into it.

If a value has the wrong type, or the JSON is broken, Bind returns an error and we answer with 400. For now the handler sends the data back, because there is no tasks table to save it in yet.

The request now needs a body, and a header that says the body is JSON:

requests.http

 ### Create task
 POST http://localhost:3000/api/v1/tasks
+Content-Type: application/json
+
+{
+  "title": "Ship v1",
+  "description": "The first release"
+}

 ### Show task
 GET http://localhost:3000/api/v1/tasks/5
 # ... 6 lines not shown
Enter fullscreen mode Exit fullscreen mode

Run it, or use curl:

$ curl -X POST http://localhost:3000/api/v1/tasks \
    -H "Content-Type: application/json" \
    -d '{"title":"Ship v1","description":"The first release"}'
Enter fullscreen mode Exit fullscreen mode

POST /api/v1/tasks

{
  "title": "Ship v1",
  "description": "The first release"
}
Enter fullscreen mode Exit fullscreen mode

A form body gives the same result, because of the form tags:

$ curl -X POST http://localhost:3000/api/v1/tasks -d "title=Ship v1&description=The first release"
Enter fullscreen mode Exit fullscreen mode

POST /api/v1/tasks

{
  "title": "Ship v1",
  "description": "The first release"
}
Enter fullscreen mode Exit fullscreen mode

A value of the wrong type fails with 400:

$ curl -X POST http://localhost:3000/api/v1/tasks \
    -H "Content-Type: application/json" \
    -d '{"title":5}'
Enter fullscreen mode Exit fullscreen mode

POST /api/v1/tasks

{
  "error": "json: cannot unmarshal number into Go struct field CreateTaskRequest.title of type string"
}
Enter fullscreen mode Exit fullscreen mode

Broken JSON, like a body that stops halfway, also gives 400, but the message does not say what is wrong:

$ curl -X POST http://localhost:3000/api/v1/tasks \
    -H "Content-Type: application/json" \
    -d '{"title":'
Enter fullscreen mode Exit fullscreen mode

POST /api/v1/tasks

{
  "error": "http: invalid Read on closed Body"
}
Enter fullscreen mode Exit fullscreen mode

The real reason is in Goravel's log file for the day, storage/logs/goravel-<date>.log:

[2026-10-22 20:42:36.254] local.error: decode json [{"title":] error: unexpected end of JSON input
Enter fullscreen mode Exit fullscreen mode

Gotcha Send the same JSON without the Content-Type header. You get 200 OK with an empty title and description, and no error. Bind reads the body based on this header. Without -H, curl labels the body as a form, so Bind looks for form fields and finds none. If a body ever comes back empty, check this header first.

$ curl -X POST http://localhost:3000/api/v1/tasks \
    -d '{"title":"Ship v1","description":"The first release"}'
{"title":"","description":""}

Other ways to read the body:

Bind reads the whole body at once. To read one value, use Input. For this JSON body:

{"title":"Ship v1","estimate":3,"urgent":true,"tags":["api","urgent"],"meta":{"priority":"high"}}
Enter fullscreen mode Exit fullscreen mode
Method Returns
Input("title") "Ship v1"
Input("missing", "none") "none"
Input("meta.priority") "high"
InputInt("estimate") 3
InputBool("urgent") true
InputArray("tags") ["api","urgent"]
InputMap("meta") {"priority":"high"}
All() the whole body as a map
  • The second argument of Input is a default for a missing key.
  • A dot reads a key inside an object.
  • Input and All also read the query string, not only the body.
  • Without the Content-Type header, none of these can read the body.

Here are all three requests together, with the code that reads each piece:

Interactive figure on DevChalk: Where a request carries data, and the code that reads each piece.

GET /api/v1/tasks/5: RouteInt("id") reads 5. GET /api/v1/tasks?status=done&page=2&filters[priority]=high: Query("status") reads "done", QueryInt("page", 1) reads 2, QueryMap("filters") reads {"priority":"high"}. POST /api/v1/tasks with a JSON body and Content-Type: application/json: Bind fills Title "Ship v1" and Description "The first release". Without the header, Bind fills nothing and the API still answers 200 OK with empty values.

Listing routes

The API now has three task routes:

$ go run . artisan route:list

  GET|HEAD     / ................................... taskflow/routes.Web.func1
  GET|HEAD     api/v1/tasks  taskflow/app/http/controllers.(*TaskController).Index
  POST         api/v1/tasks  tasks.create › taskflow/app/http/controllers.(*TaskController).Store
  GET|HEAD     api/v1/tasks/{id}  taskflow/app/http/controllers.(*TaskController).Show
  GET|HEAD     hello ............................... taskflow/routes.Web.func2
  STATIC       public ........................................................
  GET|HEAD     users ... taskflow/app/http/controllers.(*UserController).Index

                                                            Showing [7] routes
Enter fullscreen mode Exit fullscreen mode

GET and POST on api/v1/tasks are two different routes. Goravel picks one by the method of the request.

TaskFlow's routes now read the path, the query string and the body. Nothing is saved yet, because there is no database.


Previous: 02. Routing & Responses
Next: 04. Models & Migrations
Series: Goravel

Top comments (0)