Validasi & Transformasi.
Penelusuran dari prinsip dasar tentang aturan dan pedoman yang Anda pegang saat merancang API, gerbang yang dilalui setiap data klien sebelum logika bisnis apa pun berjalan. Mengapa ia ada, di mana ia tinggal, bagaimana ia bekerja di dalamnya, dengan implementasi lengkap di Go dan Python ditampilkan berdampingan.
Apa Itu & Kenapa Ada
Validasi dan transformasi bukan satu fitur yang Anda pasang. Mereka seperangkat aturan dan pedoman yang Anda pegang saat merancang API. Hampir semuanya berakar pada dua tujuan, dan dua tujuan itulah lensa yang dipakai mengambil setiap keputusan berikutnya:
Apa yang dihitung sebagai "data klien yang masuk"
Batas validasi mencakup setiap jenis data yang bisa didorong klien ke server, bukan hanya body request. Semua yang berikut ini harus diperlakukan tidak tepercaya sampai terbukti sebaliknya:
- Payload JSON: body dari request
POST/PUT/PATCH. - Parameter query: mis.
?page=2&limit=20. - Parameter path: mis.
5di/users/5. - Header: apa pun yang di-set klien pada request.
Di Mana Ia Tinggal: Tiga Lapisan
Untuk menemukan di mana validasi terjadi, Anda dulu butuh model mental standar backend: tiga lapisan yang ditumpuk, masing-masing satu tugas. Request turun melewati tumpukan dan respons naik kembali.
Alasan controller dan service sengaja dipisahkan adalah menjaga urusan HTTP (kode error, kode sukses, bentuk respons, validasi) di satu tempat, dan logika bisnis murni di tempat lain. Controller memanggil service; service boleh atau tidak memanggil repository, tergantung kebutuhan request.
Siklus Hidup Request
Request tidak muncul dengan ajaib di dalam method controller Anda. Ia tiba, dicocokkan ke route, dan baru setelah itu method controller yang terdaftar untuk route itu dipanggil. Urutan itulah kunci menempatkan validasi dengan benar.
- Data tiba. Klien membuka koneksi HTTP dan mengirim request, biasanya payload JSON, plus parameter query dan path serta header.
- Pencocokan route berjalan. Router membandingkan path yang masuk dengan pola-polanya dan memilih method controller yang terdaftar untuk route yang cocok.
- Pipeline validasi & transformasi menyala. Sebelum logika penting apa pun berjalan, controller yang cocok melewatkan data mentah lewat pipeline.
- Logika bisnis berjalan. Hanya jika data lulus, controller memanggil method service.
- Service -> repository. Service mengeksekusi logika, kadang memanggil method repository yang menyentuh database.
- Respons naik kembali. Apa pun yang dikembalikan service diserahkan ke controller, yang mengembalikannya ke pemanggil lewat koneksi HTTP.
Titik Eksekusi
Validasi dan transformasi terjadi di tepat satu titik: di dalam lapisan controller, segera setelah route dicocokkan dan sebelum method service apa pun dipanggil atau logika bisnis berjalan. Secara konkret ia diimplementasikan sebagai middleware atau fungsi utilitas yang bisa dipakai ulang, yang Anda beri skema: deskripsi bentuk data yang seharusnya, dan ia menapaki setiap field payload yang masuk terhadap skema itu.
Kenapa Kritis: Kisah 500 vs 400
Cara paling jelas merasakan kebutuhan validasi adalah melihat apa yang terjadi tanpa itu. Bayangkan API "create book" yang mengharapkan body JSON berisi field name yang harus string. Klien malah mengirim angka 0:
{ "name": 0 }Di skema Postgres Anda, kolom itu hampir pasti dideklarasikan seperti ini, kolom string yang tidak boleh null:
CREATE TABLE books ( id SERIAL PRIMARY KEY, name TEXT NOT NULL -- expects text, refuses an integer );
Tanpa validasi, 0 itu meluncur melewati controller, melewati service, turun ke repository, dan menjadi INSERT. Postgres memeriksa tipe kolom, melihat angka di tempat yang menuntut TEXT, dan menolak operasinya. Panggilan database gagal, dan klien menerima:
{ "error": "Internal Server Error" }Itu pengalaman pengguna yang buruk. 500 berbunyi "ada yang tidak terduga rusak di dalam kami" padahal kenyataannya klien yang mengirim input buruk, dan tidak memberi mereka apa pun untuk diperbaiki. Dengan pipeline validasi di titik masuk, server menangkap kesalahannya seketika, database tak pernah tersentuh, dan klien mendapat 400 Bad Request yang bisa ditindaklanjuti:
{ "errors": [ "name: expected a string, received a number" ] }Di Dalam Pipeline: Langkah demi Langkah
Pipeline yang kokoh memproses satu field dalam lapisan, dan urutannya penting: tiap pemeriksaan hanya berjalan jika yang sebelumnya lulus. Ambil satu field, name, yang diwajibkan string dengan panjang 5-100:
- Pemeriksaan keberadaan. Apakah
nameada di payload sama sekali? Jika tidak ->"name is required", berhenti. - Pemeriksaan tipe. Ada, apakah ia string, atau klien mengirim larik / boolean / angka? Jika tipe salah ->
"expected a string", berhenti. - Pemeriksaan batasan. Ia string, apakah memenuhi pembatasan? Panjang 5-100. String 2 karakter maupun satu paragraf penuh sama-sama gagal dengan pesan yang disesuaikan.
Dalam kode, library mengekspresikan ini sebagai skema deklaratif. Go umumnya memakai struct tag dengan go-playground/validator; Python memakai Pydantic, tempat model yang sama memvalidasi dan mentransformasi. Ini versi lengkap dan bisa dijalankan dari pipeline di masing-masing:
package books import ( "encoding/json" "fmt" "net/http" "strings" "github.com/go-playground/validator/v10" ) // one shared, reusable validator (the pipeline engine) var validate = validator.New() // CreateBook is the SCHEMA. The tags declare every rule: // required -> existence check // the Go type -> type check (string) // min=5,max=100 -> constraint check type CreateBook struct { Name string `json:"name" validate:"required,min=5,max=100"` } // runPipeline decodes then validates; returns 400-ready messages. func runPipeline(r *http.Request) (*CreateBook, []string) { var body CreateBook // 1. TYPE CHECK during decode: a JSON number for `name` // cannot unmarshal into a Go string -> caught here. if err := json.NewDecoder(r.Body).Decode(&body); err != nil { return nil, []string{"name: expected a string"} } // 2. EXISTENCE + CONSTRAINT checks in one pass. if err := validate.Struct(body); err != nil { return nil, formatErrors(err) } return &body, nil } // formatErrors turns validator output into human messages. func formatErrors(err error) []string { var msgs []string for _, e := range err.(validator.ValidationErrors) { f := strings.ToLower(e.Field()) switch e.Tag() { case "required": msgs = append(msgs, fmt.Sprintf("%s: is required", f)) case "min": msgs = append(msgs, fmt.Sprintf("%s: min %s chars", f, e.Param())) case "max": msgs = append(msgs, fmt.Sprintf("%s: max %s chars", f, e.Param())) default: msgs = append(msgs, fmt.Sprintf("%s: failed %s", f, e.Tag())) } } return msgs }
from fastapi import HTTPException from pydantic import BaseModel, Field, ValidationError # CreateBook is the SCHEMA. Each annotation = one rule: # no default -> existence check (required) # : str -> type check # min/max_length -> constraint check class CreateBook(BaseModel): name: str = Field(min_length=5, max_length=100) def run_pipeline(raw: dict) -> CreateBook: """Decode + validate in one call. Raises a 400-ready error on failure.""" try: # constructing the model runs all three layers # in order: existence -> type -> constraint return CreateBook(**raw) except ValidationError as e: # reshape pydantic errors into clean strings messages = [ f"{err['loc'][0]}: {err['msg']}" for err in e.errors() ] raise HTTPException(status_code=400, detail=messages) # run_pipeline({}) -> 400 ["name: Field required"] # run_pipeline({"name": 0}) -> 400 ["name: Input should be # a valid string"] # run_pipeline({"name": "ab"})-> 400 ["name: String should # have at least 5 chars"] # run_pipeline({"name": "Dune"}) ... still <5 -> 400 # run_pipeline({"name": "The Hobbit"}) -> OK
Ini bukan taksonomi tetap yang wajib dihafal, bisa lebih banyak tergantung kebutuhan, tapi dalam praktik empat rasa menutupi hampir semua yang akan Anda temui saat merancang API. Pesan yang sebenarnya adalah pertanyaan yang terus Anda tanyakan ke diri sendiri: seberapa ketat dan seberapa spesifik saya mau terhadap data ini?
Validasi Tipe
Rasa paling dasar: apakah field cocok dengan tipe data yang diharapkan API? Apakah ia string, angka, boolean, larik, atau objek JSON bertingkat, dan bukan yang lain? Ia juga bisa berlaku rekursif: field larik mungkin mewajibkan setiap elemen berupa string, jadi elemen 0 yang berupa angka adalah error tipe tersendiri.
{ "stringField":"x", "numberField":"x", "arrayField":"x", "boolField":"x" }numberField: expected number, received string arrayField: expected array, received string boolField: expected boolean, received string
{ "arrayField": [1, 2] } -> arrayField[0]: expected string, received number{ "stringField":"something", "numberField":10, "arrayField":["one","two"], "boolField":false }package validate import ( "encoding/json" "errors" "net/http" ) // *bool so we can tell "false" apart from "missing". type TypePayload struct { StringField string `json:"stringField" validate:"required"` NumberField float64 `json:"numberField" validate:"required"` // dive = recurse INTO the slice; each element required ArrayField []string `json:"arrayField" validate:"required,dive,required"` BoolField *bool `json:"boolField" validate:"required"` } func parseTypes(r *http.Request) (*TypePayload, error) { var p TypePayload dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() // reject stray keys // json.Decode enforces the BASE types: a string for // numberField / arrayField / boolField fails to unmarshal. if err := dec.Decode(&p); err != nil { return nil, errors.New("a field has the wrong data type") } // existence + recursive element check if err := validate.Struct(p); err != nil { return nil, err } return &p, nil } // {"numberField":"x"} -> expected number, received string // {"arrayField":[1,2]} -> arrayField[0]: expected string
from pydantic import BaseModel from typing import List class TypePayload(BaseModel): stringField: str numberField: float arrayField: List[str] # recursive: EVERY element a string boolField: bool # Strict, explicit type errors: # # TypePayload(stringField="x", numberField="x", # arrayField="x", boolField="x") # -> numberField: Input should be a valid number # -> arrayField: Input should be a valid list # -> boolField: Input should be a valid boolean # # TypePayload(..., arrayField=[1, 2], ...) # -> arrayField.0: Input should be a valid string # -> arrayField.1: Input should be a valid string # # TypePayload(stringField="something", numberField=10, # arrayField=["one","two"], boolField=False) -> OK
Validasi Sintaksis
Di sini tipe sudah benar (string) tapi stringnya harus mengikuti pola struktural tertentu. Algoritma validasi memeriksa bentuk nilainya:
- Email: bagian lokal, lalu karakter
@, lalu domain yang terdiri dari nama plus top-level domain (.com,.co.in,.org, ...). Ada ribuan TLD valid; struktur yang diperiksa. - Nomor telepon: kode negara diikuti jumlah digit yang benar untuk negara itu (mis. kode negara lalu angka 10 digit).
- Tanggal: harus cocok dengan struktur yang diharapkan, mis.
YYYY-MM-DD(tahun, lalu bulan, lalu hari).
{ }email: required phone: required date: required
{ "email":"randomstring", "phone":1234567, "date":"2025-11-05" }email: invalid email format phone: expected a string, received a number
{ "email":"test@test.com", "phone":"1234567", "date":"2025-11-05" } -> 200package validate import ( "errors" "regexp" ) // optional + then 7-15 digits (country code + national no.) var phoneRe = regexp.MustCompile(`^\+?[0-9]{7,15}$`) type Contact struct { Email string `validate:"required,email"` // local @ domain.tld Phone string `validate:"required"` // checked below Date string `validate:"required,datetime=2006-01-02"` // YYYY-MM-DD } // checkSyntax handles the phone pattern (validator's // built-ins cover email + date structure already). func (c Contact) checkSyntax() error { if !phoneRe.MatchString(c.Phone) { return errors.New("phone: invalid phone number format") } return nil } // "randomstring" -> email: invalid email format // phone sent as JSON number -> received a number, want string // "2025-13-40" -> date: does not match YYYY-MM-DD
from datetime import date from pydantic import BaseModel, EmailStr, Field class Contact(BaseModel): email: EmailStr # local @ domain.tld # country code + 7-15 digits phone: str = Field(pattern=r"^\+?[0-9]{7,15}$") date: date # only accepts YYYY-MM-DD # Contact(email="randomstring", phone=1234567, # date="2025-11-05") # -> email: value is not a valid email address # -> phone: Input should be a valid string # # Contact(email="bad@", ...) # -> email: there must be something after the @-sign # # Contact(email="test@test.com", phone="1234567", # date="2025-11-05") -> OK
Validasi Semantik
Tipe benar dan sintaks benar, tapi apakah nilainya masuk akal di dunia nyata? Pemeriksaan semantik meng-encode logika domain:
- Tanggal lahir tidak bisa di masa depan; jika hari ini
2025-01-11, DOB2025-01-13tidak masuk akal meski itu tanggal yang sepenuhnya valid. - Usia
365(atau430) tidak masuk akal, setidaknya belum. Rentang usia manusia yang wajar kira-kira1sampai120.
{ "dateOfBirth":"2026-06-12", "age":43 }dateOfBirth: date of birth cannot be in the future
{ "dateOfBirth":"1995-06-12", "age":430 }age: number must be less than or equal to 120
{ "dateOfBirth":"1995-06-12", "age":43 } -> 200package validate import ( "errors" "time" ) type Profile struct { DateOfBirth string `validate:"required,datetime=2006-01-02"` // gte/lte cover the "430 is impossible" semantic bound Age int `validate:"required,gte=1,lte=120"` } // Type & syntax can't express "not in the future" // semantics need real logic against the real clock. func (p Profile) checkSemantics() error { dob, err := time.Parse("2006-01-02", p.DateOfBirth) if err != nil { return errors.New("dateOfBirth: invalid date") } if dob.After(time.Now()) { return errors.New( "dateOfBirth: date of birth cannot be in the future") } return nil } // {"dateOfBirth":"2026-06-12"} -> cannot be in the future // {"age":430} -> age: must be 120 or less
from datetime import date from pydantic import BaseModel, Field, field_validator class Profile(BaseModel): dateOfBirth: date age: int = Field(ge=1, le=120) # 430 -> must be <= 120 @field_validator("dateOfBirth") @classmethod def not_in_future(cls, v: date) -> date: if v > date.today(): raise ValueError( "date of birth cannot be in the future") return v # Profile(dateOfBirth="2026-06-12", age=43) # -> dateOfBirth: date of birth cannot be in the future # Profile(dateOfBirth="1995-06-12", age=430) # -> age: Input should be less than or equal to 120 # Profile(dateOfBirth="1995-06-12", age=43) -> OK
Validasi Kompleks / Dependen
Rasa paling kuat: aturan sebuah field bergantung pada field lain. Pipeline bisa meng-encode logika lintas-field yang arbitrer:
- Konfirmasi kata sandi:
passwordConfirmationharus persis cocok denganpassword; danpasswordminimal 8 karakter. Dua string berbeda menghasilkan "passwords don't match"; kata sandi 6 karakter menghasilkan "string must contain at least 8 characters". - Syarat kondisional:
partneropsional saatmarriedfalse, tapi begitumarriedtruenamapartnerjadi wajib: "partner name is required when married is true".
{ "password":"random", "passwordConfirmation":"another", "married":false }password: string must contain at least 8 characters passwordConfirmation: passwords don't match
{ "password":"random12", "passwordConfirmation":"random12", "married":true }partner: partner name is required when married is true
{ "password":"random12", "passwordConfirmation":"random12", "married":true, "partner":"Sam" } -> 200package validate type Signup struct { Password string `json:"password" validate:"required,min=8"` // eqfield: must equal another field on the struct PasswordConf string `json:"passwordConfirmation" \ validate:"required,eqfield=Password"` Married *bool `json:"married" validate:"required"` // required_if: partner required only if married == true Partner string `json:"partner" \ validate:"required_if=Married true"` } // {password:"random", passwordConfirmation:"another", // married:false} // -> password: must be at least 8 characters // -> passwordConfirmation: passwords don't match // // {password:"random12", passwordConfirmation:"random12", // married:true} // -> partner: required when married is true
from pydantic import BaseModel, Field, model_validator class Signup(BaseModel): password: str = Field(min_length=8) passwordConfirmation: str married: bool partner: str | None = None # mode="after": runs once all fields are parsed, # so it can compare them against each other. @model_validator(mode="after") def cross_field_rules(self): if self.password != self.passwordConfirmation: raise ValueError("passwords don't match") if self.married and not self.partner: raise ValueError( "partner name is required when married is true") return self # Signup(password="random", passwordConfirmation="another", # married=False) # -> password: String should have at least 8 characters # -> Value error, passwords don't match
Transformasi sebagai Type Casting
Transformasi berarti mengeksekusi operasi pada data yang masuk untuk mengonversinya ke format yang diinginkan, entah sebelum validasi (supaya ia bisa lulus) atau setelahnya (untuk menyiapkannya bagi lapisan service). Kasus buku teksnya adalah parameter query pagination.
Anggap GET /bookmarks?page=2&limit=20 dengan syarat-syarat ini:
page, angka, lebih besar dari 0 dan lebih kecil dari 500.limit, angka, lebih besar dari 0 dan lebih kecil dari 10.000 (paling banyak 10.000 record dikembalikan sekali waktu).
page arrives as the string "2", not the number 2. Validation that demands a number fails on its very first check, even though the client did nothing wrong, strings are simply how query params work.Perbaikannya bukan error. Ia tanggung jawab server untuk meng-cast string menjadi angka lebih dulu. "Casting" adalah memaksa satu tipe data menjadi tipe lain. Cast "2" -> 2, lalu jalankan validasi numeriknya. Langkah casting itulah transformasinya.
package validate import ( "errors" "net/url" "strconv" ) type Pagination struct { Page int Limit int } // Query params are ALWAYS strings, so we must CAST // (transform) before we can VALIDATE the numbers. func parsePagination(q url.Values) (*Pagination, error) { // TRANSFORM: force the string "2" into the int 2 page, err := strconv.Atoi(q.Get("page")) if err != nil { return nil, errors.New("page: must be a number") } limit, err := strconv.Atoi(q.Get("limit")) if err != nil { return nil, errors.New("limit: must be a number") } // VALIDATE the now-numeric values if page <= 0 || page >= 500 { return nil, errors.New("page: must be 1..499") } if limit <= 0 || limit >= 10000 { return nil, errors.New("limit: must be 1..9999") } return &Pagination{Page: page, Limit: limit}, nil }
from pydantic import BaseModel, Field class Pagination(BaseModel): # Pydantic AUTO-CASTS the incoming string "2" into # int 2 (transform), THEN enforces gt/lt (validate) # -- both steps in one schema, in the right order. page: int = Field(gt=0, lt=500) limit: int = Field(gt=0, lt=10_000) # request: /bookmarks?page=2&limit=20 # (both values arrive as strings) # # Pagination(page="2", limit="20") # -> Pagination(page=2, limit=20) # real ints # # Pagination(page="0", limit="20") # -> page: Input should be greater than 0 # # Pagination(page="abc", limit="20") # -> page: Input should be a valid integer
Transformasi sebagai Normalisasi
Transformasi juga membersihkan data setelah lolos validasi, membentuk ulang menjadi yang disukai lapisan service. Server menormalkan payload dengan tenang sebelum melakukan apa pun dengannya:
- Email yang dikirim campuran huruf,
Test@TEST.com, tersimpan sebagai huruf kecil semuatest@test.com. - Telepon mendapat awalan
+yang disuntikkan di depannya. - Tanggal dihitung ulang ke format kanonis yang diharapkan backend.
{ "email":"Test@TEST.com", "phone":"1234567", "date":"2025-11-05" }{ "email":"test@test.com", "phone":"+1234567", "date":"2025-11-05" }package validate import "strings" // normalize runs AFTER validation passes and BEFORE // the data is handed to the service layer. func (c *Contact) normalize() { // lowercase + trim the email c.Email = strings.ToLower(strings.TrimSpace(c.Email)) // inject the leading + if it is missing c.Phone = strings.TrimSpace(c.Phone) if !strings.HasPrefix(c.Phone, "+") { c.Phone = "+" + c.Phone } } // "Test@TEST.com" -> "test@test.com" // "1234567" -> "+1234567"
from pydantic import BaseModel, EmailStr, field_validator class Contact(BaseModel): email: EmailStr phone: str # field_validators double as transformers: whatever # they return REPLACES the incoming value. @field_validator("email") @classmethod def lower_email(cls, v: str) -> str: return v.strip().lower() # normalize case @field_validator("phone") @classmethod def add_plus(cls, v: str) -> str: v = v.strip() return v if v.startswith("+") else "+" + v # Contact(email="Test@TEST.com", phone="1234567") # -> email="test@test.com", phone="+1234567"
Satu Pipeline Gabungan
Dalam praktik validasi dan transformasi dipasangkan menjadi satu pipeline. Alasannya lokalitas: seluruh "lapisan data input", setiap syarat dan setiap operasi yang dilakukan pada data masuk sebelum logika bisnis berjalan, tinggal di satu tempat. Anda tak pernah harus berburu lintas file untuk menemukan apa yang diharapkan sebuah endpoint atau apa yang diam-diam ia lakukan pada data. Pipeline bisa transformasi -> validasi, atau validasi -> transformasi, urutan mana pun, sesuai tuntutan kebutuhan.
Validasi Frontend vs Backend
Kesalahan umum dan berbahaya adalah memperlakukan validasi frontend sebagai pengganti validasi backend. Keduanya tidak bisa dipertukarkan, mereka melayani tujuan berbeda, dan Anda butuh keduanya untuk setiap API.
Kenapa backend tidak bisa memercayai frontend? Karena server bisa punya banyak klien berbeda. Aplikasi web yang rapi mungkin memvalidasi dengan indah, tapi API yang sama bisa juga dipukul langsung lewat API client seperti Postman atau Insomnia, tempat tidak ada frontend yang bertindak sebagai proxy sehingga tidak ada validasi frontend sama sekali. Jika backend pernah bergantung pada frontend untuk keamanan atau integritas, server akan rusak begitu klien berubah.
Saat keduanya dihubungkan dengan benar, mereka saling melengkapi: dalam form web, email tak valid memblokir submit dan menampilkan error inline, panggilan API bahkan tidak dibuat. Perbaiki ke alamat sungguhan dan submit mengirim request yang layak; payload terkirim, server memvalidasi lagi, dan mengembalikan 200. Frontend memberi umpan balik instan; backend memberi jaminannya.
Controller Beranotasi Lengkap
Merangkai gambaran utuh: sebuah controller lengkap yang mencocokkan route, menjalankan pipeline gabungan (transformasi -> validasi -> normalisasi) sebelum logika bisnis apa pun, mengembalikan 400 yang bersih saat gagal, dan baru setelah itu memanggil lapisan service, yang memanggil repository. Kedua ujung ditampilkan lengkap.
package main import ( "context" "encoding/json" "net/http" "strings" "github.com/go-playground/validator/v10" ) var validate = validator.New() // ---- schema (the gate) ---- type CreateBook struct { Name string `json:"name" validate:"required,min=5,max=100"` } type Book struct { ID int `json:"id"` Name string `json:"name"` } // ---- helper ---- func writeJSON(w http.ResponseWriter, status int, b any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(b) } // ---- CONTROLLER: owns HTTP + the validation gate ---- func createBookHandler(w http.ResponseWriter, r *http.Request) { var body CreateBook // === GATE, runs before ANY business logic === if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeJSON(w, 400, map[string]string{ "error": "name: expected a string"}) return // 400, the DB is never touched } body.Name = strings.TrimSpace(body.Name) // transform if err := validate.Struct(body); err != nil { writeJSON(w, 400, map[string]string{ "error": err.Error()}) return // 400 Bad Request, not a confusing 500 } // === only now: business logic (service -> repo) === book, err := createBook(r.Context(), body.Name) if err != nil { writeJSON(w, 500, map[string]string{ "error": "could not create book"}) return } writeJSON(w, 201, book) } // ---- service + repository (sketched) ---- func createBook(ctx context.Context, name string) (*Book, error) { // service logic ... repository INSERT ... then: return &Book{ID: 1, Name: name}, nil } func main() { http.HandleFunc("/api/books", createBookHandler) http.ListenAndServe(":8080", nil) }
from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field, field_validator app = FastAPI() # ---- schema (the gate) ---- class CreateBook(BaseModel): name: str = Field(min_length=5, max_length=100) @field_validator("name") @classmethod def trim(cls, v: str) -> str: return v.strip() # transform / normalize class Book(BaseModel): id: int name: str # ---- service + repository (sketched) ---- def create_book(name: str) -> Book: # service logic ... repository INSERT ... then: return Book(id=1, name=name) # ---- CONTROLLER ---- # FastAPI runs the CreateBook schema (the GATE) BEFORE # this function body. A bad payload never enters the # function, the client gets an automatic 422/400, so # the DB is never touched and no client mistake leaks # out as a confusing 500. @app.post("/api/books", status_code=201) def create_book_endpoint(body: CreateBook) -> Book: # validation already passed, straight to logic try: return create_book(body.name) # service -> repo except Exception: raise HTTPException( status_code=500, detail="could not create book") # run: uvicorn main:app --reload
400 instead of a confusing 500, come in four flavors (type, syntactic, semantic, complex), pair with transformations (casting and normalization) into one pipeline, and on the backend are mandatory for security and data integrity, no matter what the client does.