odata

OData v4 client for Kit - query builder, filtering, and pagination

Files

FileDescription
.editorconfigEditor formatting configuration
.gitignoreGit ignore rules for build artifacts and dependencies
.tool-versionsasdf tool versions (Zig, Kit)
LICENSEMIT license file
README.mdThis file
dev/northwind.kitREPL preload: Northwind OData v4 helpers and pre-built queries
examples/northwind.kitExample: building query URLs for the public Northwind OData v4 test service
kit.tomlPackage manifest with metadata, capabilities, and tasks
src/client.kitHTTP client wrapper for OData requests (GET, POST, PUT, PATCH, DELETE)
src/main.kitkit-odata public module exports
src/query.kitFluent query builder ($select, $filter, $orderby, $top, $skip, $expand)
src/response.kitOData response parsing for collections, entities, counts, and pagination links
src/types.kitOData error types and sort direction ADTs
tests/query.test.kitTests for query building and response parsing

Dependencies

No Kit package dependencies.

The package uses Kit standard library modules for HTTP and JSON parsing. Live service requests require network capability, for example kit run --allow=network your-script.kit.

Installation

kit add gitlab.com/kit-lang/packages/kit-odata.git

Usage

import Kit.OData as OData
import Kit.OData.{Desc}

main = fn(-env: Env) =>
  cfg = OData.config "https://services.odata.org/V4/Northwind/Northwind.svc"

  # Build a query with the fluent API
  query = OData.new-query "Products"
    |> OData.select ["ProductName", "UnitPrice"]
    |> OData.filter "UnitPrice gt 20"
    |> OData.order-by "UnitPrice" Desc
    |> OData.top 10

  println "URL: ${OData.build-url query}"

  # Execute the query and parse the collection response
  match OData.get cfg query
    | Ok resp ->
      List.map (fn(v) =>
        name = OData.get-field v "ProductName" ?? "(unknown)"
        price = OData.get-float-field v "UnitPrice" ?? 0.0
        println "${name} - $${to-string price}"
      ) resp.values
      no-op
    | Err e ->
      println "Error: ${show e}"

  # Fetch a single entity by key
  single = OData.new-query "Products" |> OData.by-key "1"
  match OData.get-entity cfg single
    | Ok json ->
      println "Product: ${OData.get-field json "ProductName" ?? "?"}"
    | Err e ->
      println "Error: ${show e}"

main

API Reference

Query Builder

FunctionDescription
OData.new-query entityCreate a query for an entity set
OData.select fieldsAdd $select fields
OData.filter exprAdd a $filter expression; multiple filters are joined with and
OData.order-by field directionAdd an $orderby clause with Asc or Desc
OData.top nSet $top
OData.skip nSet $skip
OData.expand relationAdd $expand for related entities
OData.with-countEnable $count=true
OData.search termSet $search for full-text search
OData.by-key keyBuild a single entity path such as Products(1)
OData.build-query-string queryBuild only the query string
OData.build-url queryBuild the relative OData URL

Client Functions

FunctionDescription
OData.config base-urlCreate a client config with a base service URL
OData.config-with-auth base-url authCreate a config with an Authorization header
OData.config-with-headers base-url headersCreate a config with custom headers
OData.get cfg queryExecute a query and parse a collection response
OData.get-entity cfg queryExecute a query and parse a single entity response
OData.get-raw cfg pathRaw GET request against a relative OData path
OData.create cfg entity-set bodyPOST a new entity
OData.update cfg entity-path bodyPUT an entity
OData.patch cfg entity-path bodyPATCH an entity
OData.delete cfg entity-pathDELETE an entity

Response Helpers

FunctionDescription
OData.parse-collection bodyParse an OData JSON collection with value, @odata.count, and @odata.nextLink
OData.parse-entity bodyParse a single JSON entity response
OData.get-field json fieldExtract a string field
OData.get-int-field json fieldExtract an integer field
OData.get-float-field json fieldExtract a float field
OData.get-bool-field json fieldExtract a boolean field

Error Handling

match OData.get cfg query
  | Ok resp ->
    println "Fetched ${to-string (List.length resp.values)} entities"
  | Err (ODataRequestError {message}) ->
    println "HTTP failed: ${message}"
  | Err (ODataParseError {message}) ->
    println "Bad JSON: ${message}"
  | Err (ODataProtocolError {status, message}) ->
    println "Status ${to-string status}: ${message}"
  | Err (ODataNotFound {entity, key}) ->
    println "Not found: ${entity}(${key})"

Development

Running Examples

Run the deterministic query URL example with the interpreter:

kit run examples/northwind.kit

Compile the example to a native binary:

kit build examples/northwind.kit -o northwind && ./northwind

The checked-in example imports src/main.kit directly so it works before local installation. Consumer code should import Kit.OData.

Interactive REPL

Launch an interactive REPL pre-loaded with helpers for the public Northwind OData v4 service:

kit repl --allow=network --preload dev/northwind.kit

This gives you pre-built queries (products-query, categories-query, etc.) and helper functions for exploring the service interactively:

products 10                        # List 10 products (name, price, stock)
top-priced 5                       # Top 5 most expensive products
categories                         # List all categories
customers-in "Germany"             # Customers in a country
search-products "UnitPrice gt 100" # Filter products by OData expression
products-with-categories 5         # Products with expanded category info
page "Orders" 2 10                 # Page 2, 10 results per page
get-one "Products" "1"             # Fetch a single entity by key

You can also build and run custom queries:

q = OData.new-query "Products" |> OData.filter "UnitPrice gt 50" |> OData.top 5
run q

Running Tests

Run the test suite:

kit test

Run the test suite with coverage:

kit test --coverage

Running kit dev

Run the standard development workflow (format, check, test):

kit dev

This will:

  1. Format and check source files in src/
  2. Type-check examples in examples/
  3. Run tests in tests/ with coverage

Running Parity Checks

Run the interpreter/compiler parity check for examples:

kit parity --no-spinner --failures-only

Generating Documentation

Generate API documentation from doc comments:

kit doc

Note: Kit sources with doc comments (##) will generate HTML documents in docs/*.html

Cleaning Build Artifacts

Remove generated files, caches, and build artifacts:

kit task clean

Note: Defined in kit.toml.

Local Installation

To install this package locally for development:

kit install

This installs the package to ~/.kit/packages/@kit/odata/, making it available for import as Kit.OData in other projects.

License

This package is released under the MIT License - see LICENSE for details.

Exported Functions & Types

config

Create an OData client configuration with a base service URL.

String -> Record

config-with-headers

Create an OData client configuration with custom headers.

String -> List {name: String, value: String} -> Record

config-with-auth

Create an OData client configuration with bearer token auth.

String -> String -> Record

new-query

Create a new OData query for an entity set.

String -> Record

select

Add $select fields to the query.

Record -> List String -> Record

filter

Add a $filter expression to the query.

Record -> String -> Record

order-by

Add an $orderby clause to the query.

Record -> String -> SortDirection -> Record

top

Set the $top parameter (max results).

Record -> Int -> Record

skip

Set the $skip parameter (results to skip).

Record -> Int -> Record

expand

Add an $expand clause for related entities.

Record -> String -> Record

with-count

Enable $count in the response.

Record -> Record

Set a $search term for full-text search.

Record -> String -> Record

by-key

Set a key for single entity lookup by ID.

Record -> String -> Record

build-query-string

Build the query string from a query record.

Record -> String

build-url

Build the full relative URL from a query record.

Record -> String

get

Execute a query and return a parsed collection response.

Record -> Record -> Result Record ODataError

get-entity

Execute a query for a single entity and return parsed JSON.

Record -> Record -> Result JSONValue ODataError

get-raw

Execute a raw GET against an OData path.

Record -> String -> Result String ODataError

create

Create a new entity via POST.

Record -> String -> String -> Result JSONValue ODataError

update

Update an entity via PUT.

Record -> String -> String -> Result String ODataError

patch

Partially update an entity via PATCH.

Record -> String -> String -> Result String ODataError

delete

Delete an entity via DELETE.

Record -> String -> Result String ODataError

parse-collection

Parse a collection response body.

String -> Result Record ODataError

parse-entity

Parse a single entity response body.

String -> Result JSONValue ODataError

get-field

Extract a string field from a JSON value.

JSONValue -> String -> Option String

get-int-field

Extract an int field from a JSON value.

JSONValue -> String -> Option Int

get-float-field

Extract a float field from a JSON value.

JSONValue -> String -> Option Float

get-bool-field

Extract a bool field from a JSON value.

JSONValue -> String -> Option Bool

parse-collection

Parse an OData collection response body (JSON with "value" array).

OData v4 collection responses have the shape: { "value": [...], "@odata.count": N, "@odata.nextLink": "..." }

Returns a record with values, count, and next-link fields.

String -> Result Record ODataError

match parse-collection body
  | Ok resp -> List.map show resp.values
  | Err e -> println (show e)

parse-entity

Parse an OData single entity response body.

Single entity responses are just a JSON object (no "value" wrapper).

String -> Result JSONValue ODataError

match parse-entity body
  | Ok json -> JSON.get-string json "ProductName"
  | Err e -> println (show e)

get-field

Extract a string field from a JSON value.

JSONValue -> String -> Option String

get-int-field

Extract an int field from a JSON value.

JSONValue -> String -> Option Int

get-float-field

Extract a float field from a JSON value.

JSONValue -> String -> Option Float

get-bool-field

Extract a bool field from a JSON value.

JSONValue -> String -> Option Bool

new-query

Create a new OData query for an entity set.

String -> Record

q = new-query "Products"

select

Add $select fields to the query.

Record -> List String -> Record

q |> select ["ProductName", "UnitPrice"]

filter

Add a $filter expression to the query.

Multiple filters are combined with 'and'.

Record -> String -> Record

q |> filter "UnitPrice gt 20"
q |> filter "contains(ProductName,'Chai')"

order-by

Add an $orderby clause to the query.

Record -> String -> SortDirection -> Record

q |> order-by "UnitPrice" Desc

top

Set the $top parameter (max number of results).

Record -> Int -> Record

q |> top 10

skip

Set the $skip parameter (number of results to skip).

Record -> Int -> Record

q |> skip 20

expand

Add an $expand clause to include related entities.

Record -> String -> Record

q |> expand "Category"
q |> expand "OrderDetails($select=Quantity,UnitPrice)"

with-count

Enable $count to include total result count in the response.

Record -> Record

q |> with-count

Set a $search term for full-text search.

Record -> String -> Record

q |> search "chai tea"

by-key

Set a key for single entity lookup by ID.

Record -> String -> Record

q |> by-key "42"
q |> by-key "'ALFKI'"

build-query-string

Build the query string portion of the OData URL.

Returns the query parameters as a string (without the leading '?'). If there are no query parameters, returns an empty string.

Record -> String

q = new-query "Products" |> select ["Name"] |> top 10
build-query-string q  # "$select=Name&$top=10"

build-url

Build the full relative URL path for the query.

Record -> String

q = new-query "Products" |> top 5
build-url q  # "Products?$top=5"

q = new-query "Products" |> by-key "42" |> select ["Name"]
build-url q  # "Products(42)?$select=Name"

ODataError

Error type for OData client operations. Covers HTTP failures, parse errors, and OData-specific protocol errors.

Variants

ODataRequestError {message}
ODataParseError {message}
ODataNotFound {entity, key}
ODataProtocolError {status, message}

request-error

Create an HTTP request failure error.

String -> ODataError

protocol-error

Create a non-2xx OData protocol error.

Int -> String -> ODataError

SortDirection

Sort direction for $orderby clauses.

Variants

Asc
Desc

config

Create an OData client configuration.

String -> Record

cfg = config "https://services.odata.org/V4/Northwind/Northwind.svc"
cfg = config-with-auth "https://api.example.com/odata" "Bearer token123"

config-with-headers

Create an OData client configuration with custom headers.

String -> List {name: String, value: String} -> Record

cfg = config-with-headers "https://api.example.com/odata" [
  {name: "Authorization", value: "Bearer token123"},
  {name: "X-Custom", value: "value"}
]

config-with-auth

Create an OData client configuration with bearer token auth.

String -> String -> Record

cfg = config-with-auth "https://api.example.com/odata" "Bearer token123"

get

Execute a query and return a parsed collection response.

Record -> Record -> Result Record ODataError

q = Query.new-query "Products" |> Query.top 5
match get cfg q
  | Ok resp -> List.map show resp.values
  | Err e -> println (show e)

get-entity

Execute a query for a single entity and return parsed JSON.

Record -> Record -> Result JSONValue ODataError

q = Query.new-query "Products" |> Query.by-key "1"
match get-entity cfg q
  | Ok json -> JSON.get-string json "ProductName"
  | Err e -> println (show e)

get-raw

Execute a raw GET request against an OData URL path.

Useful for following @odata.nextLink pagination URLs or accessing service metadata.

Record -> String -> Result String ODataError

match get-raw cfg "Products?$top=5"
  | Ok body -> println body
  | Err e -> println (show e)

create

Create a new entity via POST.

Record -> String -> String -> Result JSONValue ODataError

body = "{\"ProductName\": \"New Product\", \"UnitPrice\": 9.99}"
match create cfg "Products" body
  | Ok json -> println "Created!"
  | Err e -> println (show e)

update

Update an entity via PUT (full replacement).

Record -> String -> String -> Result String ODataError

body = "{\"ProductName\": \"Updated\", \"UnitPrice\": 12.99}"
match update cfg "Products(1)" body
  | Ok _ -> println "Updated!"
  | Err e -> println (show e)

patch

Partially update an entity via PATCH.

Record -> String -> String -> Result String ODataError

body = "{\"UnitPrice\": 14.99}"
match patch cfg "Products(1)" body
  | Ok _ -> println "Patched!"
  | Err e -> println (show e)

delete

Delete an entity via DELETE.

Record -> String -> Result String ODataError

match delete cfg "Products(1)"
  | Ok _ -> println "Deleted!"
  | Err e -> println (show e)