OData is a standards-based way to ship queryable, discoverable APIs. go-odata wires the protocol details for you: metadata, key parsing, canonical URLs, and query option execution. In times of AI-assisted coding, having a strict, machine-readable standard like OData helps both humans and LLMs generate correct, predictable APIs.
Currently at v0.9.0, which effectively represents the planned 1.0 API surface. Remaining work toward 1.0 is focused on cleanup, documentation, and performance—not on major feature gaps.
Why OData?
- Standardized querying ($filter, $expand, $apply) instead of custom query params
- Machine-readable metadata ($metadata) enabling automatic client generation
- Ecosystem support (Excel, Power BI, SAP, Dynamics, Graph, etc.)
- Formal protocol instead of ad-hoc REST conventions
What go-odata provides
- OData v4.01 coverage: 85+ compliance tests validating headers, payloads, query options, metadata, batch, change tracking, and errors.
-
End-to-end endpoints: Service document,
$metadata, CRUD, navigation, property accessors, composite keys, singletons, and batch. -
Query options implemented:
$filter,$select,$expand,$orderby,$top/$skip,$count,$search,$apply, and delta tokens. - Database support via GORM: SQLite, PostgreSQL, MariaDB, and MySQL exercised in CI.
- Lifecycle and read hooks: Validation, authorization, tenant scoping, redaction, and auditing without global middleware.
- Custom operations: Bound/unbound actions and functions and virtual entities.
Getting started
package main
import (
"log"
"net/http"
"github.com/nlstn/go-odata"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type Product struct {
ID uint `json:"ID" gorm:"primaryKey" odata:"key"`
Name string `json:"Name" gorm:"not null" odata:"required"`
Description string `json:"Description"`
Price float64 `json:"Price" gorm:"not null"`
Category string `json:"Category" gorm:"not null"`
}
func main() {
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
db.AutoMigrate(&Product{})
service, err := odata.NewService(db)
if err != nil {
log.Fatal(err)
}
if err := service.RegisterEntity(&Product{}); err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/", service)
log.Println("Serving OData on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
With this minimal setup you automatically get:
-
GET /service document andGET /$metadataCSDL - CRUD endpoints:
/Products,/Products(1)with optimistic concurrency via ETags - Full query option support, navigation, and property accessors
Extending behavior with hooks
Lifecycle hooks let you keep business logic close to your entities:
// Validate before writes
func (p *Product) ODataBeforeCreate(ctx context.Context, r *http.Request) error {
if p.Price < 0 {
return fmt.Errorf("price cannot be negative")
}
return nil
}
// Apply tenant filters before reads
func (p Product) ODataBeforeReadCollection(ctx context.Context, r *http.Request, opts *odata.QueryOptions) ([]func(*gorm.DB) *gorm.DB, error) {
tenantID := r.Header.Get("X-Tenant-ID")
if tenantID == "" {
return nil, fmt.Errorf("missing tenant header")
}
return []func(*gorm.DB) *gorm.DB{
func(db *gorm.DB) *gorm.DB { return db.Where("tenant_id = ?", tenantID) },
}, nil
}
No global middleware required—hooks are discovered via reflection and scoped to the entity.
Practical impact
- Automatic metadata, key parsing, and canonical URLs remove protocol boilerplate.
- Query option parsing/execution covers
$apply/$expandand other spec-required behaviors. - ETag-based optimistic concurrency and per-entity HTTP method restrictions support safer writes.
- Structured errors and tracing hooks aid debugging and observability.
- Swappable databases through GORM without changing the API layer.
Road to 1.0
v0.9.0 is focused on stability. The next steps before 1.0:
- Finalize any remaining breaking changes and API polish.
- Broaden compliance coverage as the test suite grows.
- Capture feedback from early adopters—file issues or discussions on GitHub.
Top comments (0)