Skip to content

v0.10.0

Choose a tag to compare

@vkiryakov vkiryakov released this 22 Oct 17:29
· 7 commits to dev since this release
228a6b9

Zefir 0.10.0 adds zefir.App, a container that builds a service out of its modules and runs the lifecycle of its servers and clients, and two modules: zefir-jwt, which signs and verifies the tokens of zefir services, and zefir-redis, a Redis and Valkey client for one standalone server.

App

zefir.App(Root), in zefir-core, builds a service out of modules, structs that declare what they are made of. App builds every part once and gives each the parts it asks for by their types. It starts what has to start, watches the servers, and stops everything within one deadline. The whole graph is checked when the service compiles:

pub const AppModule = struct {
    pub const module: zefir.app.Module = .{
        .imports = &.{ users.UsersModule, orders.OrdersModule },
        .providers = &.{ShopErrors},
        .factories = &.{ Bus, Servers },
        .controllers = &.{HealthController},
        // Ready objects from main: App hands them out, and never starts or
        // destroys them.
        .values = &.{ *const Config, *const log.Logger },
    };
    // ...
};

// In main: App matches the ready objects by type, and builds the rest.
var app = try zefir.App(AppModule).init(init.gpa, io, .{ &config, &logger }, .{});
defer app.deinit();
// Starts every bean, waits until the stop is over, and returns; a signal
// calls app.requestStop().
try app.run();
  • The graph.
    • Modules with providers, factories, controllers and handlers, exports and visibility, ready values from main, and overrides for tests.
    • A dependency that nothing provides, a part that a module does not see, or a cycle is a compile error that names the module, the part and the fix.
  • The lifecycle.
    • start runs in the order the beans were built, and rolls back when one fails. App then watches the servers, and one that breaks stops the application.
    • requestStop, from a signal or your code, begins a stop in two phases under one deadline, shutdown_timeout, 25 s by default. The servers stop taking work, then they all shut down together, then the clients one by one, the last built first.
    • ShutdownTimeout when the deadline runs out, and run then ends the process.
    • wait, isReady, failure and Status. App's events go to std.log, in the scope zefir_app.
  • The beans of zefir.
    • The servers and clients of http, rpc, rpc-nats, nats and mongo are beans without wrappers, and redis.Client fits the same contract.
    • The servers of http and rpc, and rpc.Client, take their parts and typed Options, which ZLS completes. The servers and the clients of nats, mongo and redis stop with shutdown(io, deadline).
    • nats.Client gets start, wait and shutdown; mongo.Client gets start, shutdown and start_timeout.

Not in this version yet, as the list at the top of the guide says:

  • a context of the application, such as one that cancels background work at the stop: a bean with work of its own ends it in its shutdown;
  • a restart: an App runs once;
  • a deadline of App's own for the start: each bean bounds its own start;
  • lazy beans: init builds every bean of every module the root reaches.

The end-to-end tests in tests/app run on zio, in CI on Linux, macOS and Windows: two servers in one App, stops under load, fail-fast, and signals and ShutdownTimeout in child processes.

See the App guide: a shop of three modules with an HTTP server and an rpc server, every file shown as it is and every run as it printed. The guides of http, rpc, rpc-nats, nats and mongo each have a section "Under zefir.App".

jwt

zefir-jwt signs and verifies the JSON Web Tokens that zefir services issue themselves: RFC 7519 tokens in the compact form of JWS, with HS256, HS384, HS512, ES256, ES384 and EdDSA. Keys come from the settings, and the claims of your application arrive in a struct of your own, checked by the rules of zefir-validator:

var key: jwt.SigningKey = try .fromPem(.EdDSA, config.auth.signing_key.expose(), .{ .id = config.auth.key_id });
defer key.deinit();
const signer: jwt.Signer = try .init(&key, .{ .issuer = "orders", .audiences = &.{"orders"} });
const keys = [_]jwt.Key{key.verificationKey()};
const verifier: jwt.Verifier = try .init(.{ .keys = &keys, .issuer = "orders", .audiences = &.{"orders"} });

// The token of user-7, a buyer, for 15 minutes.
const token = try signer.sign(Claims, io, gpa, &.{ .roles = &.{"buyer"} }, .{ .subject = "user-7" });
defer gpa.free(token);

// A route checks it; what it gives lives in the arena of the request.
const verified = try verifier.verify(Claims, io, arena, token, .{});
  • Keys.
    • HMAC secrets, and EdDSA, ES256 and ES384 keys in PEM or DER. Each key is bound to one algorithm when it loads.
    • A verifier holds several keys and picks one by kid, so a service can change its key while its replicas switch one by one.
    • SigningKey.generate makes keys for tests.
    • zefir-log writes a key as [REDACTED], and deinit wipes it from memory.
  • Claims.
    • The signer sets iss, aud, iat and exp, 15 minutes from now unless you choose another lifetime; sub comes with the call.
    • The verifier requires exp, iss and aud and checks the time with a leeway, 60 s by default. Then it fills your struct and checks its field_rules.
  • A refusal says why. Every reason has an error of its own, from BadSignature and Expired to WrongAudience and ClaimsRejected. jwt.Details names the claim or the rule at fault, for your log, and jwt.describe gives a short text for the answer to a client.
  • Safe by default.
    • The algorithm comes from the key, never from the token, and none never passes.
    • The key comes only from the verifier: jku, x5u, jwk and x5c in a header are never read.
    • A header with crit is refused.
    • ES256 and ES384 signatures are written, and accepted, with a low s only.
  • A plain library. jwt has no network code, no tasks and no global state; jwt.testing.FixedClock sets the time in tests.
  • HTTP and RPC. The guide builds both checks as code of the application: a middleware of zefir-http, JwtAuth, and an rpc.Authenticator, JwtAuthenticator.

Not in this version yet, as the list at the top of the guide says:

  • tokens of other issuers, such as Keycloak, Auth0 or Google. Even an ES256 or ES384 token that another library signed passes only with a low s;
  • JWKS by URL, OIDC discovery and RSA;
  • JWE, nested tokens, JWK and JWKS, and keys from X.509 certificates;
  • ES512 and Ed448;
  • revocation and replay protection: a token lives until it expires.

See the jwt guide: the orders and billing services of a shop, every program compiled and every run shown as it printed.

redis

redis.Client talks to one standalone server, Redis 7.2 or later or Valkey 8 or later, over RESP3, on std.Io. A command is a value, and its reply arrives as a Zig type:

var client: redis.Client = try .init(gpa, io, .{ .url = config.redis.url, .password = password });
defer client.deinit();
try client.connect(io, ctx, &diagnostics);
defer client.close(io);

// A session: its token names the customer, for half an hour.
try client.run(io, op, cmd.set("session:7f3a9c", "42", .{ .ttl = .fromSeconds(30 * 60) }), .{});
const user = try client.run(io, op, cmd.get([]const u8, "session:7f3a9c"), .{ .allocator = arena });

// The profile: a hash with a field for each field of Profile.
try client.run(io, op, cmd.hset("user:42", Profile{ .name = "Ann", .age = 30, .plan = .pro }), .{});
const profile = (try client.run(io, op, cmd.hgetall(Profile, "user:42"), .{ .allocator = arena })) orelse
    return error.ProfileGone;
  • The client.
    • A pool of connections that every task shares, first come first served. A connection that idled gets a PING before a call takes it.
    • Deadlines and cancellation from core.Context. A blocking command's timeout on the server comes from the deadline of the call.
    • A connection with an unread reply never goes back to the pool.
    • start and shutdown let zefir.App start the client and stop it with the rest of a service.
  • About 90 commands in redis.cmd: strings, keys, hashes, lists, sets, sorted sets, streams with consumer groups, scripts and pub/sub. A hash reads into a struct of yours. cmd.raw sends any other command, except those that change the state of a connection.
  • Pipelines, transactions and scripts.
    • client.pipeline sends many commands in one exchange and gives a value or an error for each.
    • conn.transaction runs MULTI/EXEC with WATCH, and gives null when a watched key changed.
    • cmd.evalScript sends a Lua script by its SHA1, and its text after NOSCRIPT.
  • Pub/sub. client.subscribe gives a redis.Subscriber on a connection of its own.
  • Errors say whether the command may have run.
    • redis.outcome(err) tells which failed calls are safe to make again.
    • Diagnostics keeps the server's code and text.
    • There are no automatic retries; the guide says which errors are worth retrying.
  • redis.testing: FakeServer, a scripted server on loopback, to test an application without Redis.

Not in this version yet, as the list at the top of the guide says:

  • Sentinel and Cluster: a client refuses a node of a cluster or a Sentinel when it connects;
  • TLS, so it needs a trusted network. Tokens that expire, as the clouds' IAM gives them, need TLS too;
  • servers older than Redis 7.2 or Valkey 8;
  • functions (FUNCTION, FCALL), the commands of modules such as JSON and Search, and caching on the client (CLIENT TRACKING).

On Windows under zio, close doesn't interrupt a read that is already waiting.

The end-to-end tests run against Redis 7.2.16, Redis 8.10.2 and Valkey 9.1.2 in Docker, in CI too.

See the redis guide: the sessions, the profiles, the orders and the best customers of a coffee shop, step by step, every program compiled and every run shown as it printed.

Also in this release

  • The module table of the README says which modules are only partly built, and what each one lacks.
  • nats paces its reconnects by the life of the last connection up to the moment its end is known. Its teardown, slow on a busy host, no longer counts.
  • rpc.testing.MemoryTransport holds a call for its whole setDelay, even where a sleep of the Io ends early, as std's does on Windows.
zig fetch --save git+https://github.com/vkiryakov/zefir#v0.10.0
const zefir = b.dependency("zefir", .{ .target = target, .optimize = optimize });
// in your module's .imports:
.{ .name = "zefir", .module = zefir.module("zefir") }, // zefir.App and zefir.app
.{ .name = "jwt", .module = zefir.module("zefir-jwt") },
.{ .name = "redis", .module = zefir.module("zefir-redis") },

A service under App, and the Redis client, also need zio; the Installation sections of the App guide and the redis guide show how to add it.

Breaking changes

The servers and clients take their parts and their options apart, and stop by a deadline, so that zefir.App can run them (#76).

http

  • http.Server.init(gpa, options) is now init(gpa, parts, options: http.Server.Options). The parts are .controllers (required), .middleware, .error_handler and .strict_errors; everything else is in Options. http.Server.Settings is no longer public.
  • .backend = b is now .listen = .{ .backend = b }, and .listen is required. New: .listen = .{ .port = N } and .listen = .{ .native = .{ … } }, where the server builds and frees its own http.native.Backend.
  • .static is a slice: .static = &.{ http.static(…) }.
  • shutdown(io) is now shutdown(io, deadline). It returns by deadline − 250 ms, and the deadline cuts .shutdown_grace. The old cap, the grace plus 1 s plus finish_timeout, is gone.
  • WaitError, and with it run and ShutdownError, gains error.Unavailable and error.Unexpected (http.Backend.FatalError): a broken backend stops its server. http.Backend.Handler has a new required field, fail.
  • http.testing.chainNames takes the type of the parts. Compile errors say "unknown part …" instead of "unknown option".

rpc

  • rpc.Server.init(gpa, options) is now init(gpa, parts, options: rpc.Server.Options). The parts are .handlers (required), .middleware and .authenticator.
  • rpc.Client.init(gpa, options) is now init(gpa, parts, options: rpc.Client.Options). It returns rpc.Client.InitError, with InvalidConfiguration for a zero max_pending or max_response_bytes, or a default_timeout of zero or less.
  • rpc.testing.Server.init(gpa, io, options) is now init(gpa, io, parts, options), and t.client(options) is now t.client(parts, options), which returns rpc.Client.InitError too.
  • rpc.Server.shutdown(io) and rpc.RawServer.shutdown(io) are now shutdown(io, deadline), and return by deadline − 250 ms.
  • rpc.Server lowers limits.default_timeout to limits.max_timeout instead of refusing it.

rpc-nats

  • rpc_nats.Transport.init(gpa, conn, options) is now init(gpa, client: *nats.Client, options). The old form is initConn(gpa, conn, options).

nats

  • nats.Error gains ConnectionLost and ReconnectFailed. An exhaustive switch on it without else needs new branches.

mongo

  • mongo.Client.Options has start_timeout, 10 s by default, and init refuses a start_timeout of zero or less with InvalidArgument.

core

  • core's promises are narrowed to its primitives. Context, Deadline, CancellationToken, the trace types and ErrorCode never allocate, read the clock or do I/O; zefir.App allocates, reads the clock and runs tasks.