DevChalk

GoravelPart 3 of 20

Reading Request Data

Path, query string and JSON body, read from the request

By Krishan Kumar7 min read

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.

CodeStart from the Routing & Response code
Terminal window
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

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 route to routes/api.go:

routes/api.go
11 collapsed lines
package routes
import (
"taskflow/app/facades"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/route"
)
func Api() {
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("tasks", func(ctx http.Context) http.Response {
return ctx.Response().String(200, "task created")
}).Name("tasks.create")
router.Get("tasks/{id}", func(ctx http.Context) http.Response {
id := ctx.Request().RouteInt("id")
return ctx.Response().Json(200, map[string]any{
"id": id,
})
})
})
}

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

Run it, or use curl:

Terminal window
curl http://localhost:3000/api/v1/tasks/5
{"id":5}

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

Terminal window
curl http://localhost:3000/api/v1/tasks/abc
{"id":0}

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 a route for the list:

routes/api.go
22 collapsed lines
package routes
import (
"taskflow/app/facades"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/route"
)
func Api() {
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("tasks", func(ctx http.Context) http.Response {
return ctx.Response().String(200, "task created")
}).Name("tasks.create")
router.Get("tasks/{id}", func(ctx http.Context) http.Response {
id := ctx.Request().RouteInt("id")
return ctx.Response().Json(200, map[string]any{
"id": id,
})
})
router.Get("tasks", func(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,
})
})
})
}
  • 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

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

Terminal window
curl -g "http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high"
{"filters":{"priority":"high"},"page":2,"status":"done"}

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

Terminal window
curl http://localhost:3000/api/v1/tasks
{"filters":{},"page":1,"status":""}
curl "http://localhost:3000/api/v1/tasks?page=abc"
{"filters":{},"page":0,"status":""}
MoreOther 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.

Code The code for every step of this part is in the tag part-03.

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 the POST /tasks route:

routes/api.go
9 collapsed lines
package routes
import (
"taskflow/app/facades"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/contracts/route"
)
type CreateTaskRequest struct {
Title string `form:"title" json:"title"`
Description string `form:"description" json:"description"`
}
func Api() {
facades.Route().Prefix("api/v1").Group(func(router route.Router) {
router.Post("tasks", func(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)
}).Name("tasks.create")
22 collapsed lines
router.Get("tasks/{id}", func(ctx http.Context) http.Response {
id := ctx.Request().RouteInt("id")
return ctx.Response().Json(200, map[string]any{
"id": id,
})
})
router.Get("tasks", func(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,
})
})
})
}

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 collapsed lines
### List tasks
GET http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high
### Hello
GET http://localhost:3000/hello

Run it, or use curl:

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

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

Terminal window
curl -X POST http://localhost:3000/api/v1/tasks -d "title=Ship v1&description=The first release"
{"title":"Ship v1","description":"The first release"}

A value of the wrong type fails with 400:

Terminal window
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title":5}'
{"error":"json: cannot unmarshal number into Go struct field CreateTaskRequest.title of type string"}

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

Terminal window
curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title":'
{"error":"http: invalid Read on closed Body"}

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

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.

Terminal window
curl -X POST http://localhost:3000/api/v1/tasks \
-d '{"title":"Ship v1","description":"The first release"}'
{"title":"","description":""}
MoreOther 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"}}
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:

Where a request carries data, and the code that reads each piece. Pick a request, then take the Content-Type header away from Create task.

Listing routes

The API now has three task routes:

Terminal window
go run . artisan route:list
GET|HEAD / ................................... taskflow/routes.Web.func1
GET|HEAD api/v1/tasks ...................... taskflow/routes.Api.func1.3
POST api/v1/tasks ....... tasks.create › taskflow/routes.Api.func1.1
GET|HEAD api/v1/tasks/{id} ................. taskflow/routes.Api.func1.2
GET|HEAD hello ............................... taskflow/routes.Web.func2
STATIC public ........................................................
GET|HEAD users ... taskflow/app/http/controllers.(*UserController).Index
Showing [7] routes

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

Code

The code for this part is in the tag part-03.

See the changesBrowse the codeDownload zip