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: querying the public Northwind OData v4 test service
kit.tomlPackage manifest with metadata and dependencies
src/client.kitHTTP client wrapper for OData requests (GET, POST, PUT, PATCH, DELETE)
src/main.kitkit-odata: OData v4 client for Kit
src/query.kitFluent query builder ($select, $filter, $orderby, $top, $skip, $expand)
src/response.kitOData response parsing (collections, entities, pagination)
src/types.kitOData error types and sort direction ADTs
tests/query.test.kitTests for query builder and response parsing

Dependencies

No Kit package dependencies.

Installation

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

Usage

import Kit.OData as OData

cfg = OData.config "https://services.odata.org/V4/Northwind/Northwind.svc"

main = fn(-env: Env) =>
  # Build a query with fluent API
  query = OData.new-query "Products"
    |> OData.select ["ProductName", "UnitPrice"]
    |> OData.filter "UnitPrice gt 20"
    |> OData.order-by "UnitPrice" Desc
    |> OData.top 10

  # Execute against the service
  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}"

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

main

Query Builder

FunctionDescription
OData.new-query entityCreate a query for an entity set
OData.select fieldsAdd $select fields
OData.filter exprAdd $filter expression (multiple are joined with and)
OData.order-by field directionAdd $orderby clause (Asc or Desc)
OData.top nSet $top (max results)
OData.skip nSet $skip (pagination offset)
OData.expand relationAdd $expand for related entities
OData.with-countEnable $count in response
OData.search termSet $search for full-text search
OData.by-key keyLook up a single entity by key

Client Functions

FunctionDescription
OData.config base-urlCreate client config with base service URL
OData.config-with-auth base-url authCreate config with Authorization header
OData.config-with-headers base-url headersCreate config with custom headers
OData.get cfg queryExecute query, return parsed collection
OData.get-entity cfg queryExecute query, return single entity JSON
OData.get-raw cfg pathRaw GET request against a path
OData.create cfg entity-set bodyPOST a new entity
OData.update cfg entity-path bodyPUT (full replace) an entity
OData.patch cfg entity-path bodyPATCH (partial update) an entity
OData.delete cfg entity-pathDELETE an entity

Error Handling

match OData.get cfg query
  | Ok resp -> use resp.values
  | 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 examples with the interpreter:

kit run --allow=network examples/northwind.kit

Compile examples to a native binary:

kit build --allow=network examples/northwind.kit -o northwind && ./northwind

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. Run tests in tests/ with coverage

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}

SortDirection

Sort direction for $orderby clauses.

Variants

Asc
Desc

ODataError

Error type for OData client operations.

ODataRequestError | ODataParseError | ODataNotFound | ODataProtocolError

Variants

ODataError

ODataRequestError

HTTP request failed.

{message: String}

Variants

ODataRequestError

ODataParseError

Response JSON could not be parsed.

{message: String}

Variants

ODataParseError

ODataNotFound

Entity not found by key.

{entity: String, key: String}

Variants

ODataNotFound

ODataProtocolError

Server returned a non-2xx status code.

{status: Int, message: String}

Variants

ODataProtocolError

SortDirection

Sort direction for $orderby clauses.

Asc | Desc

Variants

SortDirection

Asc

Ascending sort order.

Variants

Asc

Desc

Descending sort order.

Variants

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)