Skip to content

v0.11.0

Latest

Choose a tag to compare

@vkiryakov vkiryakov released this 24 Oct 19:21
· 1 commit to dev since this release
4414c59

Zefir 0.11.0 adds three modules: zefir-odm, a typed ODM for MongoDB for services with many tenants; zefir-mongo-bson, the BSON of zefir-mongo as a module of its own; and zefir-openapi, which builds the OpenAPI document of a zefir-http server from the types the server runs on. zefir-jwt becomes the first part of zefir-security, as security.jwt.

odm

zefir-odm maps the documents of MongoDB to plain Zig structs. A filter, an update or a sort is a literal that the compiler checks against the model, and what a query finds comes back as that struct. One registry keeps the connections and the tenants of a service:

// A session on the tenant's database, for this task, and the
// collection of User in it.
var acme = try registry.tenant(io, "acme");
defer acme.deinit(io);
const users = acme.collection(User);

// A customer; odm makes the id, and says which.
const ann = try users.insertOne(io, ctx, .{ .email = "ann@example.com", .name = "Ann", .age = 30 });

// The adults, the oldest first. The cursor brings them batch by batch,
// each one decoded into a User in the arena.
var adults = try users.find(io, ctx, .{ .age = .{ .gte = 18 } }, .{ .sort = .{ .age = .desc } });
defer adults.deinit(io);
while (try adults.next(io, ctx, arena)) |user| app.info("Adult", .{ .name = user.name, .age = user.age });
  • Models are plain structs. No base type and no generated code: the collection, the _id field, the BSON names and the indexes are a typed odm_schema in the struct, and the rules are the validator's field_rules.
  • Queries that the compiler checks. A field that is not there, an operator that does not fit its type, a value of the wrong type, or an update that would leave a document the model cannot read stops the build, with a message that says what to write instead. odm.Filter and odm.Update build conditions at run time.
  • Tenants, shared and on servers of their own, at once. odm.Registry binds each tenant to a connection, one server and one login, and a database of its own. Tenants come and go by events with versions while the service runs.
  • Credentials that change on the fly. A connection moves to a new login: new sessions go to the new generation, and the old one drains by leases, with no background tasks. No request fails.
  • Errors that say whether to retry. Each says whether the server may have run the call; the details are in the diagnostics of the session.
  • Shutdown within a deadline, stats(), and logs through zefir-log. The registry is a bean of zefir.App.
  • Any driver. odm has no network code: it reaches a database only through its driver contract, odm.Driver. zefir-mongo's mongo.OdmDriver is the reference driver and passes the conformance suite, odm.testing.conformance, on mongod 9.0.
  • Tests without MongoDB. odm.testing.FakeDriver answers what a test tells it to, and keeps a journal of what odm sent.

Not in this version yet, as the list at the top of the guide says, among others: TLS and mongodb+srv://, replica sets and sharded clusters (zefir-mongo connects to one standalone mongod, so keep the servers of tenants in a network you trust), transactions and change streams, and a typed language of aggregations (aggregateRaw takes BSON as it is).

The end-to-end tests in tests/odm run against mongod 9.0 in CI: the conformance suite, tenants, credential rotation, documents and the lifecycle, with stress tests besides.

See the odm guide: a quick start, models, queries, tenants, rotation, errors, zefir.App, tests and a chapter for authors of drivers, every program run against mongod and its output shown as it printed.

mongo-bson

The BSON of zefir-mongo moves into a module of its own, zefir-mongo-bson (zefir.mongo_bson), which imports nothing and is shared by zefir-mongo and zefir-odm: Zig values to BSON and back, raw documents, Extended JSON and the test vectors. New in it: bson.encodeKeyed, bson.decodeKeyed and bson.FieldKey, for fields whose BSON names differ from their Zig names, and bson.codec.

openapi

zefir-openapi builds the OpenAPI document of a zefir-http server when the service compiles, 3.1.2 by default and 3.2.1 on request, from the same types the server runs on: the routes of the controllers and what they declare, what the handlers return, the DTOs with their field_rules, the errors of each route as mapError answers them, and the middleware of each route. What the compiler cannot see, the texts, each type declares next to its code:

// A route declares what it reads, and the handler reads exactly that.
const Get = http.get("/{id}", .{ .path = dto.TodoIdPath });

pub const openapi: zefir.openapi.ControllerDoc(@This()) = .{
    .tag = "Todos",
    .routes = .{ .get = .{ .summary = "Get a todo" } },
};

pub fn get(self: *TodosController, ctx: *http.Context) !http.Json(dto.TodoResponse) {
    const in = try ctx.input(Get); // in.path is a dto.TodoIdPath, as Get declares
    const todo = self.repo.find(in.path.id) orelse return error.TodoNotFound;
    return .{ .value = .from(todo) };
}

// Where the server is built: the document comes from the type of the same parts.
s.docs = try .init(gpa, comptime zefir.openapi.document(@TypeOf(parts), .{
    .info = .{ .title = "Todos API", .version = "1.0.0" },
    .prefix = prefix,
}));
  • The document cannot drift from the server. It cannot list a route that is not there, leave out an error that a route returns, or forget a rule of a field. A route it cannot describe, or a text that does not fit, stops the build.
  • Typed texts. zefir.openapi.Doc(T), ControllerDoc(C) and MiddlewareDoc are made from the types themselves, so a field that is not there, or an example of the wrong type, is an ordinary error of Zig at the line of your code.
  • Serving and keeping it. The controller zefir.openapi.Docs serves /openapi.json with its ETag and a Scalar page at /docs. zefir.openapi.testing.expectFile keeps openapi.json in the repository up to date, and zig build openapi rewrites it.
  • The same program gives the same bytes, so the file changes only when the API does.

What it needs from the modules it stands on is new in them too:

  • zefir-validator: v.schemaOf(rule, T) gives each of the 96 kinds of rule as JSON Schema keywords, with its accuracy and a sentence for the description; a new kind of rule without a mapping does not build.
  • zefir-http: a route declares its input (.path, .query, .headers, .cookies, .body or .form) and its statuses (.responses), and a path and its DTO are checked against each other at compile time. ctx.input(Route) reads all a route declares in one call, server-sent events are typed, and http.describe(Parts) describes a server at compile time. In a Debug build, the server checks each input call and each response of a route of a documented controller against what the route declares. A route without the new options works as before.

The document is checked outside Zig as well. In tests/openapi, the regular expressions of v.schemaOf run against the validator in ajv (ECMA-262) and Go (RE2), on 89 357 vectors; every golden document and the documents of both guides are checked against the official JSON Schema of OAS 3.1 and 3.2; and the bodies the golden applications read and write are checked against their schemas. In tests/http, Schemathesis runs against the live service of the http guide and its document.

See the openapi guide, and the chapter on the document in the http guide.

security

JWT is one part of security, so zefir-jwt is no longer a module of its own: it is the first part of the planned module zefir-security, and lives there as security.jwt. The code of JWT is the same as in v0.10.0; only the imports change, see the breaking changes below. The guide moved to src/security/examples/README.md.

Also in this release

  • zefir-validator has v.closest.
  • The README's module table has one row for zefir-security, partial since it holds only JWT for now, and shows zefir-scheduler as in progress.
zig fetch --save git+https://github.com/vkiryakov/zefir#v0.11.0
const zefir = b.dependency("zefir", .{ .target = target, .optimize = optimize });
// in your module's .imports:
.{ .name = "odm", .module = zefir.module("zefir-odm") },
.{ .name = "mongo", .module = zefir.module("zefir-mongo") }, // mongo.OdmDriver
.{ .name = "mongo-bson", .module = zefir.module("zefir-mongo-bson") },
.{ .name = "security", .module = zefir.module("zefir-security") }, // security.jwt
.{ .name = "zefir", .module = zefir.module("zefir") }, // zefir.openapi

A service with odm, and one that serves its document, also needs zio; the Installation sections of the odm guide and the openapi guide show how to add it.

Breaking changes

security

  • The module zefir-jwt is now zefir-security, and JWT lives in it as security.jwt:
    • zefir.module("zefir-jwt") → zefir.module("zefir-security");
    • @import("jwt") → @import("security").jwt;
    • @import("zefir").jwt → @import("zefir").security.jwt;
    • the build step zig build test-jwt → zig build test-security.

mongo and mongo-bson

  • The BSON of zefir-mongo is a module of its own, zefir-mongo-bson: mongo.bson.X is now @import("mongo-bson").X, or @import("zefir").mongo_bson.X. Add .{ .name = "mongo-bson", .module = zefir.module("zefir-mongo-bson") } to the .imports of your module. mongo.testing.vectors is bson.testing.vectors.
  • The compile errors of the BSON codec name the types of the new module: types.DbPointer, not bson.types.DbPointer.
  • zefir-mongo imports zefir-odm, and with it zefir-validator: its reference driver of odm, mongo.OdmDriver, lives there. The API of zefir-mongo does not change, and the code of the driver compiles only when an application uses it.

odm

  • The placeholder module zefir-orm, and zefir.orm, are gone. In their place is zefir-odm (zefir.odm).

http

  • The SSE manager is typed. http.sse.Manager(Events) takes a union(enum) of the events, whose variants name the events and carry their data; publish takes .{ .event = .{ .@"todo.created" = todo } }, with .id and .retry beside it; and every http.sse.get route declares .events. An event the union does not have, or data of another type, does not build.
  • Manager(Events).PublishError holds the transform errors of every variant: InvalidEvent, EventTooLarge, OutOfMemory, and the union of v.TransformError of each variant's type. A handler that publishes a variant of plain data still gets the transform errors of the other variants in its error set, and under the strict error check (.strict_errors, on by default) mapError must map them.
  • 205 Reset Content goes without a body, with Content-Length: 0 (RFC 9110, 15.3.6), as 204 and 304 go: what the handler wrote, or the value of its http.Json(T), is left out.
  • New compile errors come with the new declarations of a route. Among them, .json or .content_type on a 204, 205 or 304 entry of .responses is refused, since those responses never have a body: .no_content = .{} is the whole entry.