CodeGym /행동 /Go SELF /Query params validation and defaults

Query params validation and defaults

Go SELF
레벨 57, 레슨 3
사용 가능

1. Why validate query params and set defaults

If you have ever written GET /api/v1/tasks?limit=abc and wondered why the server behaved “kind of strangely” — congratulations: you have already met the need for validation. Query parameters arrive as strings, and the server must decide: is this “absent”, “valid”, or “input error”? Defaults here are not a convenience, but a way to make behavior stable.

Imagine that we have a task list endpoint:

GET /api/v1/tasks

and parameters for it:

  • limit — how many tasks to return,
  • offset — from which position to start,
  • done — filter by status,
  • sort and order — sorting,
  • q — a substring for search.

All of these values arrive as strings. And without careful parsing, you will almost certainly get one of two “masterpieces”: either the server will silently ignore errors (the user gets annoyed because “but I passed the parameter!”), or it will crash (DevOps is now annoyed, and so are you, a little).

Defaults are also part of the contract. If limit was not passed, we must decide in advance: is it 20? 50? 100? If sort was not passed — do we sort by id? If done was not passed — do we show both completed and incomplete tasks? This must be defined just as clearly as the JSON format in the body.

2. Minimal parameter contract for /api/v1/tasks

To validate, we need to agree on the rules. In an API, it is useful to keep a small table of “parameter → type → default → constraints”. It helps both in implementation and in code reading, as well as in writing tests.

Parameter Type after parsing If not provided Constraints / notes
limit
int
20
1..100 (for example)
offset
int
0
0.. (non-negative)
done
“optional bool” filter is not applied only true/false are allowed
sort
string
"id"
whitelist only: "id", "title", "created_at" (example)
order
string
"asc"
only "asc" or "desc"
q
string
""
you can use TrimSpace, empty string = “no filter”

Pay attention to done: this is not just a bool. We need to distinguish between “done=false” and “the parameter done was not provided at all”. If you store this as a regular bool, both cases turn into false, and you lose information. And then the magic begins: “why did my filter for incomplete tasks turn on by itself?”.

3. General parsing and validation pattern

The good news: query parameter validation almost always fits into one repeatable pattern. The bad news: if you do not extract it into small functions, it will turn into 70 lines of if statements where nobody wants to live, including you.

In schematic form, the process looks like this:

flowchart TD
    A["r.URL.Query() → url.Values"] --> B["Extract raw strings"]
    B --> C["Parse types: int/bool"]
    C --> D["Validate: ranges/whitelist"]
    D --> E["Apply defaults"]
    E --> F["Get a struct with parameters"]

The key idea of the lecture: parameter validation should be a separate small layer that knows nothing about the database, does not sort real tasks, and does not build JSON. It simply turns “strings from the query” into “typed request settings” or returns a clear error.

4. Get() and parameter presence: absent vs empty value

The most common surprise for a beginner: q.Get("limit") returns an empty string both when the parameter is missing and when the parameter exists but is empty (?limit=). Sometimes this is fine, sometimes it is critical.

So for parameters where “presence” matters, it is better to look into the map directly:

package main

import (
	"fmt"
	"net/url"
)

func main() {
	q, _ := url.ParseQuery("done=&limit=20")

	_, hasDone := q["done"]
	_, hasOffset := q["offset"]

	fmt.Println("has done:", hasDone)     // has done: true
	fmt.Println("has offset:", hasOffset) // has offset: false
}

Here we separated “key is absent” from “key is present”. And then we can decide what to do with the empty value: treat it as an error, or interpret it as “not set”.

A practical rule: for parameters with defaults (limit, offset), an empty string can often reasonably be treated as “as if not provided” (that is, apply the default), but for filter parameters (done) an empty value more often looks like an input error. And this is not “whatever happens”, but that exact contract decision.

5. Parsing numbers: limit and offset

Numeric parameters are the main source of problems, because they affect load. limit=1000000 can turn a “simple task list” into “why is the database smoking”. That is why Atoi alone is not enough: you need limits.

Let us start with a small helper function: “take an int, if not provided — use the default, if provided — parse and check the range”.

package main

import (
	"fmt"
	"net/url"
	"strconv"
)

func parseIntInRange(q url.Values, key string, def, min, max int) (int, error) {
	raw := q.Get(key)
	if raw == "" {
		return def, nil
	}

	n, err := strconv.Atoi(raw)
	if err != nil {
		return 0, fmt.Errorf("%s must be an integer", key)
	}
	if n < min || n > max {
		return 0, fmt.Errorf("%s must be in range %d..%d", key, min, max)
	}
	return n, nil
}

Notice that we return 0 on error not because 0 “means error”, but because the value will not be used anyway if err != nil. That is a normal Go approach.

Now let us apply this to limit and offset. offset usually does not have an upper bound at the API level, but sometimes one is set anyway (for example, to avoid letting someone scroll through millions of records). For simplicity, we will restrict only limit, and check offset for non-negativity.

package main

import (
	"fmt"
	"net/url"
)

func main() {
	q, _ := url.ParseQuery("limit=50&offset=0")

	limit, err := parseIntInRange(q, "limit", 20, 1, 100)
	fmt.Println(limit, err) // 50 <nil>

	offset, err := parseIntInRange(q, "offset", 0, 0, 1_000_000)
	fmt.Println(offset, err) // 0 <nil>
}

Yes, we suddenly invented a max for offset. That is normal: the API contract is not a law of nature, you choose it. The main thing is that the choice is deliberate and stable.

6. Parsing bool: do not confuse done=false with the absence of done

With bool, there are two tasks. First — parse the string into a bool (and do not accept “yes/1/okey-dokey” if that is not what you agreed on). Second — preserve the fact that the parameter is present.

One simple option is to return a triple (value, present, err).

package main

import (
	"fmt"
	"net/url"
	"strconv"
)

func parseOptionalBool(q url.Values, key string) (value bool, present bool, err error) {
	raw, ok := q[key]
	if !ok {
		return false, false, nil
	}
	if len(raw) == 0 || raw[0] == "" {
		return false, true, fmt.Errorf("%s must be true or false", key)
	}

	v, err := strconv.ParseBool(raw[0])
	if err != nil {
		return false, true, fmt.Errorf("%s must be true or false", key)
	}
	return v, true, nil
}

Why do we not use Get? Because we need to distinguish the absence of the key (ok == false) from an empty value (?done=). And also because raw is a list of values, so we do not forget that url.Values is naturally “multi-valued”.

Mini check:

package main

import (
	"fmt"
	"net/url"
)

func main() {
	q1, _ := url.ParseQuery("")
	v, ok, err := parseOptionalBool(q1, "done")
	fmt.Println(v, ok, err) // false false <nil>

	q2, _ := url.ParseQuery("done=false")
	v, ok, err = parseOptionalBool(q2, "done")
	fmt.Println(v, ok, err) // false true <nil>
}

Now we can make an honest decision in business logic: if present == false, we do not apply the done filter. If present == true, we apply the filter by the value.

7. Whitelist for strings: sort and order

String parameters are dangerous for a different reason: they often affect SQL/sorting. Even if you do not write SQL by hand, allowing “any string” for sort is an invitation to strange edge cases. Your code should be boring: “we allow only what is known”.

Let us make a sort parser with a default and a whitelist. Note that a whitelist is convenient to store in map[string]bool: fast and readable.

package main

import (
	"fmt"
	"net/url"
	"strings"
)

func parseSort(q url.Values) (string, error) {
	raw := strings.TrimSpace(q.Get("sort"))
	if raw == "" {
		return "id", nil
	}

	allowed := map[string]bool{
		"id":         true,
		"title":      true,
		"created_at": true,
	}
	if !allowed[raw] {
		return "", fmt.Errorf("sort must be one of: id, title, created_at")
	}
	return raw, nil
}

And separately — order. Here the whitelist is even simpler:

package main

import (
	"fmt"
	"net/url"
	"strings"
)

func parseOrder(q url.Values) (string, error) {
	raw := strings.TrimSpace(q.Get("order"))
	if raw == "" {
		return "asc", nil
	}
	if raw != "asc" && raw != "desc" {
		return "", fmt.Errorf("order must be asc or desc")
	}
	return raw, nil
}

Yes, this is boring code. And that is a compliment: boring code usually does not wake you up at night.

8. Putting everything into one structure

When we have small parsers, we can gather the final result into a structure. This structure is a good candidate to pass further into the application layer (usecases), because it is already typed and valid.

Let us make TaskListQuery:

package main

type TaskListQuery struct {
	Limit  int
	Offset int

	DoneValue   bool
	DonePresent bool

	Sort  string
	Order string

	Q string
}

Note that we do not use pointers and do not go into a “nullable bool” via *bool, so as not to provoke unnecessary questions. The pair (DoneValue, DonePresent) is usually simpler for a beginner: two fields, both understandable.

Now the parseTaskListQuery function:

package main

import (
	"net/url"
	"strings"
)

func parseTaskListQuery(q url.Values) (TaskListQuery, error) {
	limit, err := parseIntInRange(q, "limit", 20, 1, 100)
	if err != nil {
		return TaskListQuery{}, err
	}
	offset, err := parseIntInRange(q, "offset", 0, 0, 1_000_000)
	if err != nil {
		return TaskListQuery{}, err
	}

	doneValue, donePresent, err := parseOptionalBool(q, "done")
	if err != nil {
		return TaskListQuery{}, err
	}

	sortBy, err := parseSort(q)
	if err != nil {
		return TaskListQuery{}, err
	}
	order, err := parseOrder(q)
	if err != nil {
		return TaskListQuery{}, err
	}

	return TaskListQuery{
		Limit:       limit,
		Offset:      offset,
		DoneValue:   doneValue,
		DonePresent: donePresent,
		Sort:        sortBy,
		Order:       order,
		Q:           strings.TrimSpace(q.Get("q")),
	}, nil
}

Notice an important style: early return on error. That is the Go pattern that makes code linear and readable. If you try to “collect all errors into one variable and return at the end”, it becomes hard to read and easy to break.

9. In the handler: parsing and error responses

What it looks like in a handler

When you write an HTTP handler, you already have *http.Request, and inside it r.URL and r.URL.Query(). Our parsing function fits there perfectly: the handler just calls it, and then decides what to do with the error.

package main

import (
	"fmt"
	"net/http"
)

func listTasksHandler(w http.ResponseWriter, r *http.Request) {
	query, err := parseTaskListQuery(r.URL.Query())
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	fmt.Fprintf(w, "limit=%d offset=%d sort=%s order=%s\n",
		query.Limit, query.Offset, query.Sort, query.Order,
	)
}

Here http.Error is used as a simple illustration: “if it is an input error — return 400”. In a real application, you will most likely respond with JSON in a unified error format, but right now the mechanism is more important to us: an error at the query validation stage is a request error, not “oops, the server broke”.

Error messages: short, but useful

An error message is part of the UX. Even if this is an API “only for our frontend”, in a week the frontend will forget what it sent, and you will be debugging it yourself.

A good query error message usually contains the parameter name and the expectation. For example, limit must be in range 1..100 is already enough to fix the request quickly. A bad message looks like invalid input, because it says nothing except “go guess”.

Another subtle point: if a parameter is absent and we have a default — that is not an error. The error begins where the parameter is present, but cannot be correctly interpreted by the contract.

10. Typical mistakes when validating query params and working with defaults

Mistake #1: “Parsed Atoi — and that is enough”.
It often seems that if strconv.Atoi("500") succeeded, then everything is fine. But without checking the range, limit=500 turns the list endpoint into “dump everything for me”. Even if you currently have 10 tasks, later there will be 10 thousand of them, and suddenly you will start suffering in production.

Mistake #2: Losing the difference between “parameter is absent” and “value = false/0”.
If you store done as a regular bool, then the absence of a parameter and done=false become the same thing. As a result, the API starts “filtering incomplete tasks” even when the user did not ask for a filter. For such parameters, store a presence flag (present) or use another explicit mechanism.

Mistake #3: Using Get() where “presence” matters.
q.Get("done") returns an empty string both if the parameter is absent and if it is present but empty. If you want to distinguish these cases, look at q["done"] and check ok, as well as the length of the list.

Mistake #4: Allow any sort and then “handle it somehow”.
Sorting should be a whitelist. Otherwise you get unstable behavior, strange code paths, and, in more realistic implementations, the risk of unsafe construction of sorting expressions. Validation should reject unknown values immediately.

Mistake #5: Mix parsing, validation, and business logic in one place.
When a handler simultaneously reads query params, parses limit, talks to storage, sorts, filters, and writes JSON, it becomes hard to test and easy to break. Keep the “query → typed struct” layer separate: it is small, repeatable, and easy to test.

1
과제
Go SELF, 레벨 57, 레슨 3
잠금
반환 항목 수 제한
반환 항목 수 제한
1
과제
Go SELF, 레벨 57, 레슨 3
잠금
done 체크박스
done 체크박스
1
과제
Go SELF, 레벨 57, 레슨 3
잠금
목록 정렬
목록 정렬
1
과제
Go SELF, 레벨 57, 레슨 3
잠금
리스트 계약
리스트 계약
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION