Alpha. Vary is under active development and not ready for production use. Syntax, APIs, performance, and behaviour may change between releases.

HTTP services

How it works

Vary lets you write a service as a plain interface and implementation, then tell the compiler how to expose it. You do not write routes, annotations, or framework boilerplate. The expose statement handles that.

expose ItemService via http

The via http part is an exposure target. Today the default HTTP backend is Javalin, but the design is intentional: expose separates what your service does from how it gets exposed. Future targets could include gRPC, WebSockets, message queues, or CLI wrappers without changing the service code. You write the interface once, and the exposure mechanism is a one-line swap.

This is still early. Only the HTTP plugin exists today. But the architecture is there for others.

The expose statement

Define a service as an interface, implement it, and the compiler generates REST and RPC endpoints:

import json

interface ItemService {
    def get_item(self, id: Int) -> Str {
    }
    def list_items(self) -> Str {
    }
    def create_item(self, name: Str, price: Int) -> Str {
    }
    def delete_item(self, id: Int) -> Str {
    }
}

class ItemServiceImpl() implements ItemService {
    def get_item(self, id: Int) -> Str {
        return json_dumps({"id": id, "name": "Widget"})
    }
    def list_items(self) -> Str {
        return json_dumps([{"id": 1, "name": "Widget"}])
    }
    def create_item(self, name: Str, price: Int) -> Str {
        return json_dumps({"created": True, "name": name})
    }
    def delete_item(self, id: Int) -> Str {
        return json_dumps({"deleted": id})
    }
}

expose ItemService via http

Running with vary run starts the Javalin-backed Vary HTTP runtime. The compiler generates framework-independent route metadata and boundary code; application code does not write JAX-RS, CDI, or other framework annotations.

HTTP verb inference

The method name prefix determines the HTTP verb:

PrefixHTTP verbExample
get_GETget_item
list_GETlist_items
find_GETfind_orders
create_POSTcreate_item
update_PUTupdate_item
patch_PATCHpatch_item
delete_DELETEdelete_item

Path generation

The base path is the interface name in kebab-case: ItemService becomes /item-service.

The method path is the name with the verb prefix stripped: get_item becomes /item.

Parameter rules

Parameters are classified based on their name and the HTTP verb:

Verbid / *_id (Int or Str)Other parameters
GETPath parameter (/item/{id})Query parameter (?name=...)
POST / PUT / PATCHPath parameterJSON body
DELETEPath parameterNot allowed (compile error)

Path parameters

Any parameter named id or ending in _id (of type Int or Str) becomes a path parameter:

def get_order(self, user_id: Int, order_id: Int) -> Str {
    # GET /order/{user_id}/{order_id}
}

Query parameters (GET)

For GET requests, non-id parameters become query parameters:

def list_notes(self, limit: Int, offset: Int) -> Str {
    # GET /notes?limit=10&offset=0
}

JSON body (POST/PUT/PATCH)

For POST, PUT, and PATCH requests, non-id parameters are sent as a JSON body:

def create_note(self, title: Str, body: Str) -> Str {
    # POST /note
    # Body: {"title": "...", "body": "..."}
}

If the method takes a single data class parameter, that data class is used directly as the request body. Otherwise, a synthetic request DTO is generated automatically.

DELETE strictness

DELETE methods only accept id parameters. This prevents ambiguous endpoints:

# OK
def delete_note(self, id: Int) {
}

# Compile error: non-id parameter 'reason'
def delete_note(self, id: Int, reason: Str) {
}

Inspecting routes

Run vary routes <file> to see your exact endpoints:

$ vary routes main.vary
NoteService (/note-service):
  GET     /note-service/health                get_health() -> Str
  GET     /note-service/notes                 list_notes() -> Str
  GET     /note-service/note/{id}             get_note(id: Int) -> Str
  POST    /note-service/note                  create_note(title: Str, body: Str) -> Str
          Body: { title: Str, body: Str }
  DELETE  /note-service/note/{id}             delete_note(id: Int) -> Str

  GET     /q/openapi                          OpenAPI spec
  GET     /q/swagger-ui                       Swagger UI
FlagEffect
--jsonMachine-readable JSON output
--openapiOpenAPI JSON generated from route metadata
--curlCopy-paste curl examples

When you run vary run, a route summary is printed in the server startup banner.

RPC fallback

Every method also gets an RPC endpoint at POST /<service-path>/rpc/<method_name> that accepts a JSON body with named parameters.

OpenAPI / Swagger

Every exposed interface gets OpenAPI documentation automatically:

EndpointPurpose
GET /q/openapiOpenAPI spec
GET /q/swagger-uiSwagger UI

Synthetic request DTOs appear as named schemas in the OpenAPI spec, making the API self-documenting.

Typed service clients

While expose defines the server side, service declarations define typed client stubs that consume HTTP APIs:

service UserApi at "https://api.example.com" {
    endpoint get_user(id: Int) -> User via GET "/users/{id}"
    endpoint create_user(body: CreateUserRequest) -> User via POST "/users"
    endpoint delete_user(id: Int) -> Str via DELETE "/users/{id}"
}

Service declarations specify:

ElementDescription
Base URLThe explicit network authority generated clients are allowed to call
EndpointsName, parameters, return type, method, path
Body parameterFor POST/PUT/PATCH, body is the request body

Endpoint declarations can also carry the public route contract metadata used by vary routes, OpenAPI generation, route manifests, and drift tests:

service UserApi at "https://api.example.com" {
    endpoint list_users(limit: Int) -> UserList
        via GET "/users"
        auth none
        cache public(max_age=60, s_maxage=300)

    endpoint update_user(id: Int, body: UpdateUserRequest) -> User
        via PATCH "/users/{id}"
        auth capability users.write
        cache private_no_store
}

Supported auth policies are auth none, auth optional, auth session, and auth capability name. Supported cache policies are cache public(max_age=N, s_maxage=N) and cache private_no_store. Additional capability name or capabilities a, b clauses record route-level capability requirements for tools and tests. See Public API security for the production VARY_AUTH_MODE/OIDC configuration these clauses depend on, and Public JSON APIs for how handlers read the verified RequestContext.

Generated service clients are constructed from the declaration itself:

let users = UserApiClient().with_header("Authorization", token)
let response = users.get_user(42)

The generated client binds requests to the declared authority, encodes path and query parameters through runtime service encoders, JSON-encodes request bodies, and decodes successful responses against the endpoint return schema. Schema mismatches become ServiceResponse decode errors instead of unchecked JSON values. Non-2xx JSON error bodies remain available through body_json() while ok() is False.

Request-derived public input must be validated or encoded before it reaches an outbound service declaration. Tainted[T] parameters and bodies are rejected in outbound endpoints, so a public handler should validate first and pass the approved domain value or string:

def search(raw: Tainted[Str]) -> ServiceResponse {
    let q = raw.validate_str(1, 120).unwrap()
    return UserApiClient().search_users(q, 1)
}

Generated clients also attach JSON accept metadata, retryability metadata, and deterministic idempotency keys for body-changing requests. The HTTP mock transport records request headers and bodies, so replay tests can exercise typed client interactions without live network dependencies.

Generating client stubs

Use vary client to generate service declarations from existing expose statements:

vary client main.vary            # print to stdout
vary client main.vary -o client.vary  # write to file

This inspects the module's exposed interfaces and produces matching service declarations.

Typed service route contracts

service endpoints can carry static route contract metadata beside the method, path, request type, and response type:

service IssueTrackerService at "https://issue-tracker.local" {
    endpoint get_issues(params: Str) -> Str via GET "/api/v1/issues"
        auth none
        cache public(max_age=60, s_maxage=300)
        capability issues.private.read
        fields public IssuePublicFields.LIST

    endpoint post_issue(body: Str) -> Str via POST "/api/v1/issues"
        auth session
        cache private_no_store
}

Supported auth policies are auth none, auth optional, auth session, and auth capability name. Cache policies are cache public(...) with optional max_age and s_maxage, or cache private_no_store. Capability clauses accept dotted names and are emitted as structured route metadata. fields public Name records the public field policy name for artifacts that need to distinguish public and private response shapes.

Existing endpoints without these clauses continue to compile and receive explicit default metadata in route JSON.

Stdlib modules

Two stdlib modules support typed service communication:

ModuleFunctions
service_clientlow-level client construction for internal/runtime use
service_responsedecode(response), decode_typed(response, descriptor), from_error(error), from_result(result)

Drift detection

The VCA005 check rule compares service declarations against expose statements and flags mismatches in endpoint names, HTTP methods, paths, parameter types, and return types. See Check rules for details.

Host restrictions

Configure allowed and denied hosts in vary.toml under [services]. The VCS008 rule enforces these restrictions. See Configuration for details.

Port configuration

Set VARY_HTTP_PORT to change the listening port (default: 8080).