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
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,
+ })
+}
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)
})
}
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 anint64.
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:
$ 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:
$ 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 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,
+ })
+}
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)
})
}
-
Queryreads"status"as a string. If there is no status, you get an empty string. -
QueryIntreads"page"as a number. Its second argument, 1, is the default when there is no page. -
QueryMapcollects the keys that start with"filters", likefilters[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:
$ curl -g "http://localhost:3000/api/v1/tasks?status=done&page=2&filters[priority]=high"
GET /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:
$ curl http://localhost:3000/api/v1/tasks
{"filters":{},"page":1,"status":""}
$ curl "http://localhost:3000/api/v1/tasks?page=abc"
{"filters":{},"page":0,"status":""}
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 |
-
QueryBoolalso reads1,yesandonas true. Anything else is false. -
Queriesjoins a key that repeats into one string. -
BindQueryfills a struct from the query string, usingformtags.
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
CreateTaskRequest describes the body we expect. The tags tell Goravel which key goes into which field:
-
json:"title"reads thetitlekey from a JSON body intoTitle. -
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
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"}'
POST /api/v1/tasks
{
"title": "Ship v1",
"description": "The first release"
}
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"
POST /api/v1/tasks
{
"title": "Ship v1",
"description": "The first release"
}
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}'
POST /api/v1/tasks
{
"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:
$ curl -X POST http://localhost:3000/api/v1/tasks \
-H "Content-Type: application/json" \
-d '{"title":'
POST /api/v1/tasks
{
"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-Typeheader. You get200 OKwith an empty title and description, and no error.Bindreads the body based on this header. Without-H, curl labels the body as a form, soBindlooks 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"}}
| 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
Inputis a default for a missing key. - A dot reads a key inside an object.
-
InputandAllalso read the query string, not only the body. - Without the
Content-Typeheader, 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
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)