Standard library · Network

net/http/validate

Checking form input and answering with a 400.

import net.http.validate

Five small checks for input that arrives at a net/http handler: Required, Length, IsEmail, Matches and Assert. The first four take a string and return true or false, so they work anywhere, not only in handlers. Assert is the one that answers: when its condition is false it sends a 400 with a JSON message. For patterns beyond Matches, such as finding or replacing, use regex directly; for typed JSON bodies, decode with encoding/json and check the fields after.

What the checks really test:

  • Required only rejects the empty string. A value of spaces passes; trim it with str.Trim first.
  • IsEmail only looks for an @ and a dot anywhere, so @. passes. It catches typing slips, nothing more.
  • Matches searches, it does not match the whole value: anchor the pattern with ^ and $. A pattern that does not compile returns false for every value, which looks like bad input, so test your patterns.
  • Assert does not stop the handler, and it pastes the message into the JSON without escaping. Return right after a failed Assert, and keep user input out of its message.

Common rules

Each function answers one question about one string. Matches takes a regular expression; anchor it with ^ and $, or it accepts any text that merely contains a match. Length counts characters, not bytes, so a Persian name of four letters has length 4 although it takes eight bytes.

net-http-validate-rules.salam
import net.http.validate
import str

func check(label: str, ok: bool):
    println label + ": " + (ok ? "ok" : "rejected")
end

func main:
    check("empty name", validate.Required(""))
    check("name of spaces", validate.Required("   "))
    check("trimmed spaces", validate.Required(str.Trim("   ")))
    check("username 'ali_75'", validate.Matches("ali_75", "^[a-z][a-z0-9_]{2,15}$"))
    check("username '7up'", validate.Matches("7up", "^[a-z][a-z0-9_]{2,15}$"))
    check("postal code 1234567890", validate.Matches("1234567890", "^[0-9]{10}$"))
    check("color #1e90ff", validate.Matches("#1e90ff", "^#[0-9a-fA-F]{6}$"))
    check("password of 6", validate.Length("abc123", 8, 64))
    check("Persian name of 4 letters", validate.Length("مریم", 2, 4))
    println "bytes in the name:", "مریم".len()
    check("email a@b.ir", validate.IsEmail("a@b.ir"))
    check("email @.", validate.IsEmail("@."))
end
Outputempty name: rejected name of spaces: ok trimmed spaces: rejected username 'ali_75': ok username '7up': rejected postal code 1234567890: ok color #1e90ff: ok password of 6: rejected Persian name of 4 letters: ok bytes in the name: 8 email a@b.ir: ok email @.: ok

Two of the results are worth a second look. Required only checks that the text is not empty, so a value of spaces passes; trim it first with str.Trim. IsEmail only checks for an @ and a dot, so it catches typing mistakes, not fake addresses. The only real proof that an address works is a confirmation email.

Report every problem at once

A form with several fields should tell the user about all the mistakes in one answer, not one per try. Run every check, collect the messages, and send them together with a 422. Building the JSON with json.Str and json.Array escapes the messages correctly.

net-http-validate-signup.salam
import net.http
import net.http.validate
import net.router
import encoding.json
import str

func signup(ctx: i64):
    name := str.Trim(http.Form(ctx, "name"))
    email := str.Trim(http.Form(ctx, "email"))
    password := http.Form(ctx, "password")
    mut problems := Vector {} as Vector<str>
    if not validate.Required(name): problems.push(json.Str("name is required")) end
    if not validate.IsEmail(email): problems.push(json.Str("email is not valid")) end
    if not validate.Length(password, 8, 64): problems.push(json.Str("password needs 8 to 64 characters")) end
    if problems.len() > 0:
        http.SetStatus(ctx, 422)
        http.Json(ctx, "{" + json.MemberRaw("errors", json.Array(problems)) + "}")
        ret
    end
    http.SetStatus(ctx, 201)
    http.Json(ctx, "{" + json.Member("welcome", name) + "}")
end

func send(form: str):
    mut headers := http.NewHeaders()
    headers.insert("Content-Type", "application/x-www-form-urlencoded")
    res := http.PostWith("http://127.0.0.1:19768/signup", form, headers)
    println http.Status(res), http.Body(res)
end

func main:
    mut r := router.NewRouter()
    router.Post(r, "/signup", signup)
    mut s := http.NewServer(19768)
    http.Use(s, r)
    _ := http.RunBackground(s)
    send("name=+&email=ali&password=123")
    send("name=Ali&email=ali%40example.com&password=123")
    send("name=Ali&email=ali%40example.com&password=long+enough")
end
Output422 {"errors": ["name is required", "email is not valid", "password needs 8 to 64 characters"]} 422 {"errors": ["password needs 8 to 64 characters"]} 201 {"welcome": "Ali"}

Stop at the first problem with Assert

For a simple API, Assert is shorter: when the condition is false it answers 400 with {"error": message}. It does not stop the handler, so keep the result in a variable and return when it is false. Otherwise the code after it runs and its response replaces the error.

net-http-validate-assert.salam
import net.http
import net.http.validate
import net.router

func transfer(ctx: i64):
    amount := http.Query(ctx, "amount")
    account := http.Query(ctx, "to")
    ok_amount := validate.Matches(amount, "^[1-9][0-9]{0,6}$")
    validate.Assert(ctx, ok_amount, "amount must be a whole number from 1 to 9999999")
    if not ok_amount: ret end
    ok_account := validate.Matches(account, "^IR[0-9]{24}$")
    validate.Assert(ctx, ok_account, "to must be an IBAN such as IR followed by 24 digits")
    if not ok_account: ret end
    http.Json(ctx, `{"sent":` + amount + `,"to":"` + account + `"}`)
end

func main:
    mut r := router.NewRouter()
    router.Get(r, "/transfer", transfer)
    mut s := http.NewServer(19769)
    http.Use(s, r)
    _ := http.RunBackground(s)
    base := "http://127.0.0.1:19769/transfer"
    each query in ["?amount=0&to=IR1", "?amount=500&to=IR1", "?amount=500&to=IR123456789012345678901234"]:
        res := http.Get(base + query)
        println http.Status(res), http.Body(res)
    end
end
Output400 {"error":"amount must be a whole number from 1 to 9999999"} 400 {"error":"to must be an IBAN such as IR followed by 24 digits"} 200 {"sent":500,"to":"IR123456789012345678901234"}

Assert puts the message into the JSON as it is, so write it without double quotes or backslashes. For messages that include user input, build the answer with json.Member as in the previous section.

Which function for which job

JobFunction
The field must be filled inRequired after str.Trim
A minimum and maximum number of charactersLength
Something that looks like an email addressIsEmail
A fixed shape: digits, a code, a phone numberMatches with an anchored pattern
Answer 400 when a check failsAssert, then return

Functions

Required

func Required(value: str): bool
True when the value is not the empty string; spaces count as a value.
net-http-validate-Check.salam
import net.http
import net.http.validate
import net.router

func signup(ctx: i64):
    email := http.Form(ctx, "email")
    validate.Assert(ctx, validate.IsEmail(email), "invalid email")
    if validate.IsEmail(email):
        http.Text(ctx, "welcome")
    end
end

func main:
    println validate.Required(""), validate.Required("  "), validate.Required("Ali")
    println validate.Length("hello", 2, 5), validate.Length("hello world", 2, 5)
    println validate.IsEmail("ali@example.com"), validate.IsEmail("ali@")
    println validate.Matches("09121234567", "^09[0-9]{9}$"), validate.Matches("12345", "^09[0-9]{9}$")
    mut r := router.NewRouter()
    router.Post(r, "/signup", signup)
    mut s := http.NewServer(19625)
    http.Use(s, r)
    _ := http.RunBackground(s)
    mut headers := http.NewHeaders()
    headers.insert("Content-Type", "application/x-www-form-urlencoded")
    bad := http.PostWith("http://127.0.0.1:19625/signup", "email=x", headers)
    println http.Status(bad), http.Body(bad)
    good := http.PostWith("http://127.0.0.1:19625/signup", "email=a%40b.ir", headers)
    println http.Status(good), http.Body(good)
end
Outputfalse true true true false true false true false 400 {"error":"invalid email"} 200 welcome

Length

func Length(value: str, min_len: int, max_len: int): bool
True when the number of characters (not bytes) is between the minimum and the maximum, both included.

The example for this item is shown with the ones above: net-http-validate-Check.salam

IsEmail

func IsEmail(value: str): bool
A very simple email check: the text has to hold an @ and a dot, in any place. To be sure, send a confirmation email.

The example for this item is shown with the ones above: net-http-validate-Check.salam

Matches

func Matches(value: str, pattern: str): bool
True when the regular expression matches somewhere in the value; anchor it with ^ and $ to test the whole value. False when the pattern does not compile.

The example for this item is shown with the ones above: net-http-validate-Check.salam

Assert

func Assert(ctx: i64, ok: bool, message: str)
When the condition is false, sets status 400 and the body {"error":"message"}, with the message not escaped. It does not stop the handler.

The example for this item is shown with the ones above: net-http-validate-Check.salam