Mert Özen Her Satırda Daha İleri
Backend Go ile Backend — #10

Go'da JSON ve Request Validation: encoding/json, json/v2, Struct Tag'leri ve Body Sınırları

Mert Özen 15 Kas 2026 14 dk 3 görüntülenme
Go'da JSON ve Request Validation: encoding/json, json/v2, Struct Tag'leri ve Body Sınırları

Go'da bir JSON body'yi güvenle okumak için Content-Type, MaxBytesReader ile boyut sınırı, DisallowUnknownFields ve tek değer kontrolü gerekir; encoding/json v1 ise alan adlarını büyük/küçük harfe bakmadan eşleştirip tekrar eden alanda sonuncuyu alır. Bookstore'da POST /api/orders, *int ile eksik değer, errors.Join ağacından validation listesi, ASP.NET Core ile aynı sözleşmede Problem Details, Go 1.27'nin json/v2 farkları ve fiyatı float64 yerine kuruşla taşımak.

Sürüm notu: Bu yazıdaki kodlar Go 1.27.4 ile derlenip çalıştırılmıştır; module'ün go satırı 1.26.0'dır ve uygulama kodu Go 1.26.11 ile de derlenir. API encoding/json (v1) kullanıyor. encoding/json/v2 ve encoding/json/jsontext Go 1.27'de standart kütüphaneye girmiştir; onları gösteren lab //go:build go1.27 ile ayrılmıştır. omitzero Go 1.24 ile gelmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'si bu yazıda ilk kez sipariş alıyor. Bir istemci POST /api/orders'a şu body'yi gönderiyor; aynı alan iki kez var, ikincisi büyük harfle yazılmış:

curl -i -X POST -H 'Content-Type: application/json' \
  --data '{"customerId":"ada","CUSTOMERID":"mallory","items":[{"isbn":"9780441172719","quantity":1}]}' \
  http://127.0.0.1:52710/api/orders

Request struct'ında json:"customerId" tag'li tek bir field var ve decoder'a bilinmeyen field'ları reddetmesini söyledik. Cevap 400 mü olur, yoksa sipariş Ada'ya mı, Mallory'ye mi yazılır?

HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/orders/ORD-1001
X-Request-Id: guess
Content-Length: 197

{"id":"ORD-1001","customerId":"mallory","status":"Pending","items":[{"isbn":"9780441172719","title":"Dune","unitPrice":34990,"quantity":1}],"total":34990,"createdAt":"2026-11-15T01:55:12.329694Z"}

Sipariş Mallory'ye yazıldı. encoding/json field adlarını büyük/küçük harfe bakmadan eşleştiriyor, bu yüzden CUSTOMERID bilinmeyen bir field sayılmadı; aynı field iki kez gelince de sonuncusu kazandı. Bir proxy ya da WAF (Web Application Firewall; HTTP trafiğini filtreleyen güvenlik katmanı) ilk değere, Go servisi son değere bakıyorsa bu fark bir güvenlik açığı olabilir. Go 1.27'deki encoding/json/v2 ikisini de farklı ele alıyor. Bu yazıda JSON okuma ve yazmayı, request validation'ı, hataları Problem Details olarak döndürmeyi ve sonunda POST /api/orders'ı göreceğiz.

Struct Tag'leri ve DTO'lar

HTTP katmanı domain tiplerini doğrudan JSON'a çevirmiyor; istek ve cevap için ayrı DTO'lar (Data Transfer Object; katmanlar arasında veri taşıyan basit tipler) var, tıpkı ASP.NET Core Web API serisinin "DTO ve Mapping" yazısındaki gibi. Bu tipler internal/httpapi'de, yani C# tarafında Bookstore.Api'nin DTO'larının durduğu katmanda. Go'da bunlar struct tag'leriyle JSON adlarına bağlanıyor:

// placeOrderRequest, POST /api/orders'ın body'si.
type placeOrderRequest struct {
    CustomerID string             `json:"customerId"`
    Items      []orderItemRequest `json:"items"`
}
type orderItemRequest struct {
    ISBN     string `json:"isbn"`
    Quantity *int   `json:"quantity"` // nil: field hiç gelmedi, 0 değil
}
// orderResponse, API'nin bir sipariş için döndürdüğü şey.
type orderResponse struct {
    ID         string              `json:"id"`
    CustomerID string              `json:"customerId"`
    Status     string              `json:"status"`
    Items      []orderItemResponse `json:"items"`
    Total      catalog.Money       `json:"total"` // her fiyat gibi kuruş cinsinden
    CreatedAt  time.Time           `json:"createdAt"`
}
func newOrderResponse(o *orders.Order) orderResponse {
    items := make([]orderItemResponse, 0, len(o.Items)) // asla nil değil: null değil [] yazılır
    for _, it := range o.Items {
        items = append(items, orderItemResponse{ISBN: it.ISBN, Title: it.Title, UnitPrice: it.UnitPrice, Quantity: it.Quantity})
    }
    return orderResponse{ID: o.ID, CustomerID: o.CustomerID, Status: o.Status.String(),
        Items: items, Total: o.Total(), CreatedAt: o.CreatedAt.UTC()}
}

Quantity *int bilinçli bir seçim. int olsaydı, body'de quantity hiç yokken de 0 görürdük ve "adet girilmedi" ile "adet 0" arasındaki farkı kaybederdik. Pointer field'da eksik değer nil oluyor; C#'taki int?'in karşılığı. Cevap tarafında ise tersini yapıyoruz: items her zaman boş da olsa bir slice; 03'te gördüğümüz gibi nil slice v1'de null yazılır ve Angular tarafında order.items.length hata verir.

Tag'deki omitempty boş değerleri (0, "", nil, boş slice) yazmıyor, ama struct'lara etki etmiyor: sıfır bir time.Time yine "0001-01-01T00:00:00Z" olarak gidiyor. Go 1.24'teki omitzero bunu çözüyor; tipin zero value'suysa, ya da tipin IsZero() method'u true dönüyorsa field yazılmıyor. Aşağıdaki json/v2 lab'ının son satırı bu farkı gösteriyor.

Body'yi Güvenle Okumak: Decoder, DisallowUnknownFields, MaxBytesReader

Body'yi okuyan fonksiyon dört şeyi kontrol ediyor: Content-Type, boyut sınırı, bilinmeyen field'lar ve body'de tek bir JSON değeri olması.

// errUnsupportedMediaType, body JSON değilse döner.
var errUnsupportedMediaType = errors.New("Content-Type must be application/json")

// decodeJSON, en fazla maxBodyBytes olan bir body'den tam olarak bir JSON object okur
// ve dst'de olmayan field'ları reddeder.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
    if mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mt != "application/json" {
        return errUnsupportedMediaType
    }
    r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()
    if err := dec.Decode(dst); err != nil {
        return err
    }
    if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
        return errors.New("body must contain a single JSON object")
    }
    return nil
}

// writeDecodeError, decodeJSON'ın hatasını bir problem cevabına çevirir.
func writeDecodeError(w http.ResponseWriter, r *http.Request, err error) {
    var tooLarge *http.MaxBytesError
    switch {
    case errors.Is(err, errUnsupportedMediaType):
        writeProblem(w, r, Problem{Status: http.StatusUnsupportedMediaType, Detail: err.Error()})
    case errors.As(err, &tooLarge):
        writeProblem(w, r, Problem{Status: http.StatusRequestEntityTooLarge,
            Detail: fmt.Sprintf("body must be at most %d bytes", tooLarge.Limit)})
    default:
        writeProblem(w, r, Problem{Status: http.StatusBadRequest, Detail: err.Error()})
    }
}

http.MaxBytesReader, body 64 KB'ı geçince okumayı *http.MaxBytesError ile kesiyor ve server'a bu bağlantıyı kapatmasını söylüyor; sınır olmasaydı biri gigabaytlarca veri göndererek belleği doldurabilirdi. DisallowUnknownFields, yanlış yazılmış ya da beklenmeyen field'ları sessizce yok saymak yerine hata veriyor. Decode akıştan sadece ilk JSON değerini okuduğu için ikinci bir Decode ile body'nin bittiğini doğruluyoruz. Farklı hatalı istekler şunları döndürdü (sadece detail ve status gösteriliyor):

{"coupon":"BOOK10", ...}                   400  json: unknown field "coupon"
{"customerId":"ada",                       400  unexpected EOF
{"quantity":"2", ...}                      400  json: cannot unmarshal string into Go struct field placeOrderRequest.items.0.quantity of type int
{...}{"customerId":"ada"}                  400  body must contain a single JSON object
70 KB body                                 413  body must be at most 65536 bytes
Content-Type: text/plain                   415  Content-Type must be application/json

Bu davranış ASP.NET Core'da varsayılan olarak geliyor: [ApiController] model binding hatalarında kendiliğinden 400 döndürüyor ve Kestrel'in varsayılan bir body sınırı var. Go'da hepsini kendin yazıyorsun; karşılığında ne olduğunu tek fonksiyonda görüyorsun.

Validation: Elle mi, validator ile mi?

Doğrulamayı iki katmana böldüm. HTTP katmanı sadece isteğin biçimine bakıyor: quantity eksikse daha domain'e gitmeden 400 dönüyor. İş kuralları ise 05'ten beri orders.Service.Place'te: müşteri ID'si zorunlu, adet pozitif. Service bütün sorunları errors.Join ile tek bir hatada topluyordu; 05'in ★★★ alıştırmasında istediğim fonksiyonu şimdi orders package'ına ekledim:

// ValidationErrors, err'in ağacındaki bütün ValidationError'ları sırayla döndürür.
// Hem tekli wrapping'i (%w) hem errors.Join'i dolaşır.
func ValidationErrors(err error) []ValidationError {
    var out []ValidationError
    var walk func(error)
    walk = func(e error) {
        switch x := e.(type) {
        case nil:
        case ValidationError:
            out = append(out, x)
        case interface{ Unwrap() []error }:
            for _, inner := range x.Unwrap() {
                walk(inner)
            }
        case interface{ Unwrap() error }:
            walk(x.Unwrap())
        }
    }
    walk(err)
    return out
}

Fonksiyon, hata ağacını iki tür Unwrap üzerinden dolaşıyor: %w ile sarılmış tek bir hata Unwrap() error, errors.Join ise Unwrap() []error sağlıyor. Handler bu listeyi ASP.NET Core'un ValidationProblemDetails'ındaki errors biçimine çeviriyor:

// writeValidationProblem bütün geçersiz field'ları,
// ASP.NET Core'un ValidationProblemDetails biçiminde listeler.
func writeValidationProblem(w http.ResponseWriter, r *http.Request, verrs []orders.ValidationError) {
    fields := make(map[string][]string, len(verrs))
    for _, v := range verrs {
        fields[v.Field] = append(fields[v.Field], v.Message)
    }
    writeProblem(w, r, Problem{Status: http.StatusBadRequest,
        Title: "One or more validation errors occurred.", Errors: fields})
}
{"customerId":"","items":[{"isbn":"9780441172719","quantity":0},{"isbn":"9780141439587"}]}
{"customerId":"","items":[{"isbn":"9780441172719","quantity":0}]}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"instance":"/api/orders","errors":{"items[1].quantity":["is required"]},"traceId":"missing"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"instance":"/api/orders","errors":{"customerId":["is required"],"items[0].quantity":["must be positive"]},"traceId":"invalid"}

İlk istekte biçim hatası olduğu için domain kontrolüne hiç gidilmedi; ikinci istekte service'in iki hatası birlikte listelendi. İki aşamalı yapının bedeli bu: istemci bazı hataları ikinci denemede görüyor. Alternatif, go-playground/validator (v10) gibi struct tag'lerine kural yazan bir library: validate:"required,min=1" gibi tag'ler, ASP.NET Core Web API serisinin "Model Validation" yazısındaki Data Annotations'a çok benziyor. Kural sayısı arttıkça işe yarıyor; ama iş kurallarını HTTP DTO'suna taşıma eğilimi yaratıyor. Bookstore'da kurallar az ve domain'e ait olduğu için elle yazılmış doğrulamada kaldım.

Problem Details (RFC 9457) ile Hata Cevapları

09'a kadar hatalar http.Error ile düz metin olarak gidiyordu. Temmuz 2023 tarihli RFC (Request for Comments; internet standartlarının yayımlandığı belge serisi) 9457, RFC 7807'nin yerini aldı ve HTTP API'leri için standart bir hata biçimi tanımlıyor: application/problem+json media type'ı ve type, title, status, detail, instance member'ları; ihtiyaç olursa ek member'lar. ASP.NET Core Web API serisinin "Global Hata Yönetimi" yazısında .NET'in bu biçimi nasıl ürettiğini görmüştük. Bookstore'un Angular istemcisi hem .NET hem Go API'sinden aynı sözleşmeyi görsün diye type URI'lerini (Uniform Resource Identifier; bir kaynağı tanımlayan adres) ve ek member adlarını ASP.NET Core'dakiyle aynı tuttum:

// Problem, RFC 9457'deki problem details body'si. Errors ve TraceID
// extension member'lar; adları ASP.NET Core'daki gibi.
type Problem struct {
    Type     string              `json:"type"`
    Title    string              `json:"title"`
    Status   int                 `json:"status"`
    Detail   string              `json:"detail,omitempty"`
    Instance string              `json:"instance,omitempty"`
    Errors   map[string][]string `json:"errors,omitempty"`
    TraceID  string              `json:"traceId,omitempty"`
}
// problemTypes, ASP.NET Core ile aynı type URI'lerini kullanır; böylece Angular
// istemcisi iki API'den de aynı sözleşmeyi görür.
var problemTypes = map[int]string{
    http.StatusBadRequest:            "https://tools.ietf.org/html/rfc9110#section-15.5.1",
    http.StatusNotFound:              "https://tools.ietf.org/html/rfc9110#section-15.5.5",
    http.StatusConflict:              "https://tools.ietf.org/html/rfc9110#section-15.5.10",
    http.StatusRequestEntityTooLarge: "https://tools.ietf.org/html/rfc9110#section-15.5.14",
    http.StatusUnsupportedMediaType:  "https://tools.ietf.org/html/rfc9110#section-15.5.16",
    http.StatusUnprocessableEntity:   "https://tools.ietf.org/html/rfc4918#section-11.2",
    http.StatusInternalServerError:   "https://tools.ietf.org/html/rfc9110#section-15.6.1",
}
// writeProblem ortak member'ları doldurur ve p'yi application/problem+json olarak yazar.
func writeProblem(w http.ResponseWriter, r *http.Request, p Problem) {
    if p.Type == "" {
        p.Type = problemTypes[p.Status]
    }
    if p.Type == "" {
        p.Type = "about:blank"
    }
    if p.Title == "" {
        p.Title = http.StatusText(p.Status)
    }
    p.Instance = r.URL.Path
    p.TraceID = RequestIDFrom(r.Context())
    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(p.Status)
    if err := json.NewEncoder(w).Encode(p); err != nil {
        log.Printf("write problem: %v", err)
    }
}

traceId'ye 09'daki request ID'yi koyuyorum; kullanıcının bildirdiği hatayı log'da bulmak için tek gereken bu değer. Bilinmeyen bir hata olduğunda detail boş kalıyor ve ayrıntı sadece log'a gidiyor. Go'nun http.StatusText'i bazı başlıklarda ASP.NET Core'dan farklı: 413 için Go "Request Entity Too Large", ASP.NET Core "Content Too Large" diyor. İstemci title'a değil status'a ve type'a bakmalı.

POST /api/orders: İlk Sipariş HTTP Üzerinden

Handler bütün parçaları birleştiriyor: body'yi okuyor, biçimi kontrol ediyor, Place'i isteğin context'iyle çağırıyor ve hatayı tipine göre bir status code'a çeviriyor. Başarılı siparişte 201 ve yeni kaynağın adresini Location'da döndürüyor.

func (a *API) placeOrder(w http.ResponseWriter, r *http.Request) {
    var req placeOrderRequest
    if err := decodeJSON(w, r, &req); err != nil {
        writeDecodeError(w, r, err)
        return
    }

    lines := make([]orders.Line, 0, len(req.Items))
    var missing []orders.ValidationError
    for i, it := range req.Items {
        if it.Quantity == nil {
            missing = append(missing, orders.ValidationError{Field: fmt.Sprintf("items[%d].quantity", i), Message: "is required"})
            continue
        }
        lines = append(lines, orders.Line{ISBN: it.ISBN, Quantity: *it.Quantity})
    }
    if len(missing) > 0 {
        writeValidationProblem(w, r, missing)
        return
    }

    o, err := a.Orders.Place(r.Context(), req.CustomerID, lines)
    if verrs := orders.ValidationErrors(err); len(verrs) > 0 {
        writeValidationProblem(w, r, verrs)
        return
    }
    switch {
    case errors.Is(err, catalog.ErrBookNotFound):
        writeProblem(w, r, Problem{Status: http.StatusUnprocessableEntity, Detail: err.Error()})
        return
    case errors.Is(err, orders.ErrOutOfStock):
        writeProblem(w, r, Problem{Status: http.StatusConflict, Detail: err.Error()})
        return
    case err != nil:
        log.Printf("place order: %v rid=%s", err, RequestIDFrom(r.Context()))
        writeProblem(w, r, Problem{Status: http.StatusInternalServerError})
        return
    }

    w.Header().Set("Location", "/api/orders/"+o.ID)
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    if err := json.NewEncoder(w).Encode(newOrderResponse(o)); err != nil {
        log.Printf("write json: %v", err)
    }
}
curl -i -X POST -H 'Content-Type: application/json' \
  --data '{"customerId":"grace","items":[{"isbn":"9780441172719","quantity":2},{"isbn":"9780141439587","quantity":1}]}' \
  http://127.0.0.1:52710/api/orders
curl http://127.0.0.1:52710/api/orders/ORD-1002
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/orders/ORD-1002
X-Request-Id: happy
Content-Length: 265

{"id":"ORD-1002","customerId":"grace","status":"Pending","items":[{"isbn":"9780441172719","title":"Dune","unitPrice":34990,"quantity":2},{"isbn":"9780141439587","title":"Emma","unitPrice":19900,"quantity":1}],"total":89880,"createdAt":"2026-11-15T01:55:12.34426Z"}
{"id":"ORD-1002","customerId":"grace","status":"Pending","items":[{"isbn":"9780441172719","title":"Dune","unitPrice":34990,"quantity":2},{"isbn":"9780141439587","title":"Emma","unitPrice":19900,"quantity":1}],"total":89880,"createdAt":"2026-11-15T01:55:12.34426Z"}

Stokta olmayan bir kitap 409, katalogda olmayan bir ISBN (kitaplara verilen uluslararası standart numara) 422 alıyor:

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"detail":"place order: \"The Hobbit\" has 0 in stock, 1 requested: out of stock","instance":"/api/orders","traceId":"stock"}
{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Unprocessable Entity","status":422,"detail":"place order: isbn 9780000000000: book not found","instance":"/api/orders","traceId":"isbn"}

Handler'ların main'deki karşılığı tek satır: API'ye orders.NewService(memstore.New(), books) ile kurulan service veriliyor. 08'de service'i ve memstore'u eşzamanlı kullanıma uygun hâle getirdiğimiz için server'ın her isteği ayrı bir goroutine'de işlemesi artık sorun değil. 09'daki CrossOriginProtection da bu ilk POST endpoint'ini korumaya başladı.

encoding/json/v2 ve jsontext

Go 1.27 ile standart kütüphaneye iki package girdi: encoding/json/v2 ve onun altında çalışan, JSON'u token ve değer seviyesinde işleyen encoding/json/jsontext. Release notes'a göre v1 artık v2'nin üzerinde çalışıyor; davranışı korunuyor ama hata mesajlarının metni değişebiliyor ve unmarshal belirgin şekilde hızlandı. Bir sorun çıkarsa GOEXPERIMENT=nojsonv2 ile eski implementasyona dönülebiliyor. Farkları bir lab'da yan yana koydum:

//go:build go1.27

// Lab 10: encoding/json (v1) ve encoding/json/v2 yan yana.
package main

import (
    jsonv1 "encoding/json"
    "encoding/json/jsontext"
    "encoding/json/v2"
    "fmt"
    "time"
)

// shipment, bir time.Time üzerinde omitempty ve omitzero'yu (Go 1.24) gösterir.
type shipment struct {
    ShippedAt   time.Time `json:"shippedAt,omitempty"`  // bir struct hiçbir zaman "boş" sayılmaz
    DeliveredAt time.Time `json:"deliveredAt,omitzero"` // zero time yazılmaz
}

type request struct {
    CustomerID string   `json:"customerId"`
    Coupons    []string `json:"coupons"`
}

func main() {
    // 1) nil bir slice.
    empty := request{CustomerID: "ada"}
    b1, _ := jsonv1.Marshal(empty)
    b2, _ := json.Marshal(empty)
    b3, _ := json.Marshal(empty, json.FormatNilSliceAsNull(true))
    fmt.Printf("v1:      %s\nv2:      %s\nv2+null: %s\n", b1, b2, b3)

    // 2) Aynı key iki kez, farklı büyük/küçük harfle.
    body := []byte(`{"customerId":"ada","CUSTOMERID":"mallory"}`)
    var r1, r2 request
    err1 := jsonv1.Unmarshal(body, &r1)
    err2 := json.Unmarshal(body, &r2)
    fmt.Printf("v1: %q err=%v\nv2: %q err=%v\n", r1.CustomerID, err1, r2.CustomerID, err2)

    // 3) Birebir aynı key iki kez.
    dup := []byte(`{"customerId":"ada","customerId":"mallory"}`)
    var d1, d2, d3 request
    err1 = jsonv1.Unmarshal(dup, &d1)
    err2 = json.Unmarshal(dup, &d2)
    err3 := json.Unmarshal(dup, &d3, jsontext.AllowDuplicateNames(true))
    fmt.Printf("v1: %q err=%v\nv2: %q err=%v\nv2+allow: %q err=%v\n", d1.CustomerID, err1, d2.CustomerID, err2, d3.CustomerID, err3)

    // 4) Bir string içinde geçersiz UTF-8.
    bad := []byte("{\"customerId\":\"ad\xffa\"}")
    var u1, u2 request
    err1 = jsonv1.Unmarshal(bad, &u1)
    err2 = json.Unmarshal(bad, &u2)
    fmt.Printf("v1: %q err=%v\nv2: %q err=%v\n", u1.CustomerID, err1, u2.CustomerID, err2)

    // 5) omitempty zero bir time.Time'ı dışarıda bırakamaz; omitzero bırakır.
    s1, _ := jsonv1.Marshal(shipment{})
    fmt.Printf("%s\n", s1)
}
go run ./labs/10-jsonv2
v1:      {"customerId":"ada","coupons":null}
v2:      {"customerId":"ada","coupons":[]}
v2+null: {"customerId":"ada","coupons":null}
v1: "mallory" err=<nil>
v2: "ada" err=<nil>
v1: "mallory" err=<nil>
v2: "ada" err=jsontext: duplicate object member name "customerId"
v2+allow: "mallory" err=<nil>
v1: "ad�a" err=<nil>
v2: "" err=jsontext: invalid UTF-8 within "/customerId" after offset 17
{"shippedAt":"0001-01-01T00:00:00Z"}

v2 nil slice'ı [] olarak yazıyor; v1 davranışı gerekiyorsa json.FormatNilSliceAsNull(true) option'ı var. Field adlarını büyük/küçük harfe duyarlı eşleştiriyor, bu yüzden Önce Tahmin Et'teki CUSTOMERID artık customerId'yi ezmiyor. Aynı adın iki kez geçtiği object'i ve geçersiz UTF (Unicode Transformation Format; Unicode karakterlerini byte'lara çeviren kodlama ailesi) dizisi içeren, yani geçerli UTF-8 olmayan string'leri reddediyor; v1 birinde son değeri alıyor, diğerinde geçersiz byte'ı sessizce � karakterine çeviriyordu. Option'lar, C#'taki JsonSerializerOptions gibi tek bir nesne değil, her çağrıya verilebilen değerler: json.Marshal(v, opts...). Bookstore'un API'si Go 1.26 ile de derlenmek zorunda olduğu için v1'de kalıyor; 14'te v2'ye geçişi konuşacağız.

Para: float64 Değil

03'ten beri fiyatları kuruş olarak int64 tabanlı Money tipinde tutuyoruz. Go'da .NET'teki decimal gibi yerleşik bir ondalık tip yok; float64 ise ikili tabanda çalıştığı için 349,90 gibi değerleri tam tutamıyor:

// Lab 10: JSON'da fiyat taşımanın üç yolu.
package main

import (
    "encoding/json"
    "fmt"

    "bookstore/internal/catalog"
)

// Decimal, fiyatı JSON'da "349.90" gibi bir string olarak yazar.
type Decimal catalog.Money

// MarshalText, encoding/json'ın değeri bir JSON string'i olarak yazmasını sağlar.
func (d Decimal) MarshalText() ([]byte, error) {
    return fmt.Appendf(nil, "%d.%02d", d/100, d%100), nil
}

func main() {
    // float64: 3 Dune artı 1 Emma, ardından %10 kurumsal indirim.
    dune, emma, rate := 349.90, 199.00, 0.9 // değişken: sabitlerdeki kesin aritmetik devreye girmesin
    subtotal := dune*3 + emma
    f := subtotal * rate
    fmt.Println("float64:", subtotal, f)

    // int kuruş: aynı hesap; yuvarlama kuralı açıkça yazılı.
    sub := catalog.Money(34990)*3 + 19900
    k := sub * 90 / 100 // tam sayı bölmesi: aşağı yuvarlar
    fmt.Println("kurus:  ", int64(sub), int64(k), k)

    out, _ := json.Marshal(map[string]any{"float": f, "kurus": k, "decimal": Decimal(k)})
    fmt.Println(string(out))
}
go run ./labs/10-money
float64: 1248.6999999999998 1123.83
kurus:   124870 112383 1123.83 TRY
{"decimal":"1123.83","float":1123.83,"kurus":112383}

Ara toplam 1248.6999999999998 oldu. Son sonuç bu örnekte doğru göründü, ama binlerce satırı toplayan bir raporda bu küçük farklar kuruşlara dönüşür. API'de iki seçenek var. Kuruşu tam sayı olarak göndermek (34990) basit ve kayıpsız; istemci gösterirken 100'e bölüyor. Değeri "349.90" gibi bir string olarak göndermek ise okunabilir ve istemcinin kendi decimal tipine kayıpsız parse etmesine izin veriyor. .NET API'si decimal'ı JSON'da sayı olarak (349.90) yazıyor; JavaScript bunu yine float64 olarak okuduğu için Angular tarafında aynı sorun başka bir yerde ortaya çıkabiliyor. Bookstore'un Go API'si kuruşla devam ediyor; bu karar API sözleşmesinin bir parçası ve dokümante edilmeli.

Üretimde Dikkat

Decoder'ın hata mesajını olduğu gibi istemciye göndermek iç ayrıntı sızdırıyor: placeOrderRequest.items.0.quantity bir Go tip adı içeriyor ve 1.27'de v1'in mesaj metinleri değişebiliyor. Production'da *json.UnmarshalTypeError ve *json.SyntaxError'ı errors.As ile yakalayıp kendi mesajını üretmek daha doğru; UnmarshalTypeError'ın Field alanı JSON yolunu veriyor.

Body sınırı her endpoint için aynı olmak zorunda değil. Sipariş için 64 KB fazlasıyla yeter, toplu kurumsal sipariş yükleyen bir endpoint daha büyük bir sınır isteyebilir. Sınırı handler seviyesinde vermek bu yüzden middleware'den daha esnek.

v2'ye geçiş, davranış değişikliği demek. Nil slice'ın [] olması istemciyi bozmaz, ama büyük/küçük harf duyarlılığı bozabilir: CustomerId gönderen eski bir istemci v2'de sessizce boş değer gönderiyormuş gibi görünür. Geçişten önce gerçek trafikten örnek isteklerle test et.

Sık Düşülen Tuzaklar

Body'yi sınırsız okumak. io.ReadAll(r.Body) ya da sınırsız bir json.NewDecoder(r.Body), istemcinin gönderdiği her şeyi belleğe alır. Her zaman MaxBytesReader kullan.

Eksik değerle sıfırı karıştırmak. "Adet girilmedi" ile "adet 0" farklı hatalar; pointer field ya da omitzero ile bu farkı koru.

Fiyatı float64 ile taşımak. Lab'da gördüğümüz gibi ara sonuçlar kayar. Kuruş ya da string decimal kullan.

Header'ları status'tan sonra ayarlamak. placeOrder'da Location ve Content-Type, WriteHeader(http.StatusCreated)'dan önce ayarlanıyor; sonra ayarlansaydı gönderilmezdi.

Validation hatalarını tek tek döndürmek. İlk hatada durmak, istemciyi aynı formu üç kez göndermeye zorlar. errors.Join ve ValidationErrors ile hepsini tek cevapta listele.

Küçük Bir Deneme

★☆☆ POST /api/orders'a bir sipariş gönder, sonra Location'daki adresi curl ile çağır. Aynı body'yi Content-Type olmadan gönderince hangi cevabı alıyorsun?

★★☆ writeDecodeError'a *json.UnmarshalTypeError için bir dal ekle ve "quantity":"2" gönderildiğinde errors içinde items[0].quantity: must be a number dönecek şekilde düzenle.

★★★ Money'ye MarshalText/UnmarshalText ekleyerek fiyatı JSON'da "349.90" biçiminde taşıyan bir sürüm yaz. Kaç endpoint'in cevabı değişti? Bu değişiklik istemci için bir breaking change mi?

API artık sipariş alıyor, ama siparişler process yeniden başlayınca kayboluyor. Bir sonraki yazıda database/sql ve go-mssqldb ile aynı BookstoreDb'ye bağlanıyoruz: connection pool ayarları, context'li sorgular, sipariş ve stok düşümünü tek bir transaction'da yapmak, 04'ten beri hiç çalıştırılmamış sqlstore'u gerçek bir SQL Server'a karşı çalıştırmak ve sqlc ile Dapper'a en yakın Go deneyimi.