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:
| Prefix | HTTP verb | Example |
|---|---|---|
get_ | GET | get_item |
list_ | GET | list_items |
find_ | GET | find_orders |
create_ | POST | create_item |
update_ | PUT | update_item |
patch_ | PATCH | patch_item |
delete_ | DELETE | delete_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:
| Verb | id / *_id (Int or Str) | Other parameters |
|---|---|---|
| GET | Path parameter (/item/{id}) | Query parameter (?name=...) |
| POST / PUT / PATCH | Path parameter | JSON body |
| DELETE | Path parameter | Not 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
| Flag | Effect |
|---|---|
--json | Machine-readable JSON output |
--openapi | OpenAPI JSON generated from route metadata |
--curl | Copy-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:
| Endpoint | Purpose |
|---|---|
GET /q/openapi | OpenAPI spec |
GET /q/swagger-ui | Swagger 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:
| Element | Description |
|---|---|
| Base URL | The explicit network authority generated clients are allowed to call |
| Endpoints | Name, parameters, return type, method, path |
| Body parameter | For 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:
| Module | Functions |
|---|---|
service_client | low-level client construction for internal/runtime use |
service_response | decode(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).