Teyru

Framework: a Spring-shaped container and web layer

Dependency injection and request binding are both done at compile time, instead of scanning with reflection at startup the way Spring does.

lib/17_container.teyru and lib/18_web.teyru, together with internal/sema/framework*.go, make up an application framework whose interfaces are copied from Spring but whose implementation differs.

Reflection, and the one thing that is not

Spring scans the classpath at startup, reads annotations, uses reflection to create and inject beans, and binds requests to methods. This container reads annotations and uses reflection to create and inject too (lib/28_container_reflect.teyru). What differs is the bean list: there is no classpath to scan, so the compiler writes that list down, and everything else about a bean is read off the class while the program starts.

The differences between the two are concrete:

SpringTeyru
Where beans come fromScanning the classpath at runtimeThe compiler writes down the classes annotated with @Component and the like; there is no classpath to scan
Reading annotations and injectingReflection at runtimeReflection at runtime, reading the class itself
Missing beanNoSuchBeanDefinitionException, at startupIllegalStateException, at refresh()
Circular dependencyBeanCurrentlyInCreationException, at startupIllegalStateException, at refresh(), with the cycle in the message
Adding a bean at runtimePossibleNot possible: the list comes from the compiler
Startup costScanning and reflectionNone — the registry is filled in by static initialization

The trade-off is explicit: no runtime extension. The benefit is equally explicit: a whole class of errors moves from "only seen after deployment" to "doesn't compile".

Container

import teyru.*

@Repository
class UserRepo {
  String find(String id) { return "user " + id }
}

@Service
class UserService {
  @Autowired UserRepo repo
  @Value("${app.name:unnamed}") String appName
  private boolean ready = false

  @PostConstruct
  void start() { ready = true }

  String describe() { return appName + ": " + repo.find("7") }
}

class Main {
  public static void main(String[] args) {
    ApplicationContext ctx = new ApplicationContext()
    ctx.setProperty("app.name", "demo")
    ctx.refresh()
    UserService s = (UserService) ctx.getBean(UserService.class)
    System.out.println(s.describe())
  }
}

Supported annotations

AnnotationMeaning
@Component@Service@Repository@Controller@RestControllerThis class is a bean; the name defaults to the class name with the first letter lowercased, and @Component("x") can change it
@Configuration + @BeanThis class is itself a bean too, and each of its @Bean methods produces one bean
@AutowiredThe field, constructor parameter, or @Bean method parameter is provided by the container
@Qualifier("name")Names which one when several of the same type exist
@PrimaryThe default choice when several of the same type exist
@Value("${key}")@Value("${key:default}")Injects a property
@PostConstructCalled after construction and injection (no parameters, returns void)
@Scope("prototype")A new instance is built on every lookup; the default is singleton

Constructor selection

The rule since Spring 4.3: if there is only one constructor, use it; otherwise find the one marked @Autowired; otherwise use the no-argument one. With the constructor that was chosen, every parameter must itself be marked @Autowired (or @Value) to be injected: an unmarked parameter is not filled in, the generated factory calls the constructor with the missing arguments, and the compile fails with TY-TYP-0072. If several are marked @Autowired, or none are and there are several with no no-argument constructor, it is TY-TYP-0105.

Lifecycle

refresh() builds every singleton, so failures in construction and injection are reported at startup (rather than on the first request) — a missing bean is caught even earlier, as the compile-time TY-TYP-0103. Bean creation is recursive: to get A, the B it needs is built first. The creating flag blocks cycles.

@PostConstruct is called by the generated injector after injection completes. @PreDestroy is declared but not executed — Teyru has no process shutdown hook, and the container has no close().

Web layer

import teyru.*

@RestController
@RequestMapping("/pets")
class PetController {
  @Autowired PetStore store

  @GetMapping("/{id}")
  Pet one(@PathVariable("id") int id) { return store.find(id) }

  @GetMapping("")
  String list(@RequestParam(value = "q", defaultValue = "all") String q) {
    return "q=" + q
  }

  @PostMapping("")
  Pet create(@RequestBody String body) { return new Pet(body, 1) }
}
AnnotationMeaning
@RequestMapping (on the class)The path prefix
@RequestMapping (on the method)The path and method (omitted means any verb)
@GetMapping@PostMapping@PutMapping@DeleteMapping@PatchMappingThe path and the verb
@PathVariableThe value of {name} in the path, converted to the parameter's type
@RequestParamA query parameter; defaultValue can supply a default
@RequestHeaderA request header (names are case-insensitive); defaultValue is the value used when the header is absent, and with none given it is an empty string
@RequestBodyThe request body; if the parameter is a String it is received as-is, and if it is a class (or record) it is parsed with the Gson binding
@ResponseBodyDeclared; @RestController already implies it, so it makes no difference whether it is present

Parameter conversion: path and query parameters are both strings, so parameters of type int, long, double, float, short, byte, boolean are converted via the corresponding parseX (int uses Integer.parseInt).

Request body: when the @RequestBody parameter is a String, you get the body as-is (this is how you take a payload you want to inspect yourself); when it is a class or record, it is parsed by the Gson binding the compiler generates for that type — Spring picks a message converter based on Content-Type, whereas here the type is already known at compile time, and the converter is that binding. A failed parse throws the same JsonSyntaxException. A type with no JSON mapping is a compile error, not an exception on the first request.

Response: returning an HttpResponse decides everything yourself; returning a String is text/plain; returning void is an empty body; anything else is written as application/json by the Gson binding (see docs/json.md) — records, enums (written as the constant's name), arrays, List and Map alike. The old "primitive types have no mapping" diagnostic, TY-TYP-0111, is gone: the binding takes every type now.

Handler parameters: a parameter of type HttpRequest is the request itself, whatever it is named — the way Spring hands a handler its HttpServletRequest. Any other unannotated parameter is still a query parameter of the same name.

Routing: Router.match takes the most specific match — a literal segment beats a variable segment, so /pets/mine is not swallowed by /pets/{id}, regardless of registration order. A path that exists with the wrong verb is 405, and a path that does not exist is 404.

Server

ApplicationContext ctx = Application.boot(args)
Router router = Application.routerFrom(ctx)
HttpServer server = new HttpServer(port, router, ctx)

HttpServer.handle(HttpRequest) is the only entry point once a request arrives — it is separate from the socket loop, so it can be tested without a connection: that is how tests/programs/t102_web.teyru and tests/programs/t141_web_param_errors.teyru are tested, the latter covering the 400 from a failed conversion, enum parameters and return values, and defaultValue.

Starting up, and settings

class Main {
  public static void main(String[] args) {
    SpringApplication.run(Main.class, args)
  }
}

SpringApplication.run is new ApplicationContext() plus SpringApplication.loadConfig(ctx, args) plus ctx.refresh(). Settings come from application.properties in the working directory (--spring.config.name= points at another file) and then from --key=value arguments on the command line, which win. Read one back with ctx.getProperty("app.name") or ctx.getProperty("app.name", "fallback"), and ask whether one exists with ctx.hasProperty(...).

@ConfigurationProperties(prefix = "app") on a bean binds app.* into its fields, with relaxed names: app.max-size and app.max_size both reach maxSize. A bean under @Profile("prod") is built only when that profile is active (--spring.profiles.active=prod). A @PreDestroy method runs when the context is closed with ctx.close().

The concerns that cross requests

thinghow it is declaredwhat it does
@ControllerAdvice with @ExceptionHandler(X.class)the advice on the class, the handler on a methoda controller that throws X (or a subclass) is answered by this method, whose return value is the response — an HttpResponse, a ResponseEntity or a body
HandlerInterceptora bean implementing itpreHandle runs before every route and answering false is a 403; afterCompletion runs before the answer is written
static filesspring.web.static=<dir>a path no route claims is a file in that directory, its content type from its extension; a path containing .. is a 404
CORSspring.web.cors=origin,origina preflight is answered by the server and never reaches a controller, and the headers go on the answer that is finally sent

A route is called reflectively, so what a controller throws arrives wrapped in an InvocationTargetException; the wrapper is taken off before the advice is chosen, so advice matches the exception the controller meant.

Sessions

@GetMapping("/cart")
String cart(HttpRequest req) {
  HttpSession s = Sessions.of(req)
  Object n = s.getAttribute("count")
  int v = n == null ? 0 : ((Integer) n).intValue()
  s.setAttribute("count", Integer.valueOf(v + 1))
  return "count=" + s.getString("count")
}

Sessions.of(req) finds the session the request's cookie names, or makes one and puts a Set-Cookie (TEYRUSSESSIONID, HttpOnly) on the answer the server is about to send. That is the only place it can go: a route's response is built after the handler has returned, which is also why a handler is given the request rather than an injected response.

isNew() is true only for the request that made the session. find(req) looks one up without making one, which is what a login page reads. invalidate() takes the id out of the store, so the cookie the client still holds names nothing and the next request gets a fresh session. attributeNames() keeps the order attributes were first set in. Sessions.count() and clear() are for tests.

Expiry. setMaxInactiveInterval(seconds) gives one session an idle limit and Sessions.setTimeout(seconds) gives the whole store a default; 0 (or anything negative) means no limit, a session's own interval wins when it has one, and otherwise the store default applies. The setting file takes Boot's server.servlet.session.timeout, whose value is a duration — 30m, 2h, 1d, 45s — or a bare number of seconds; spring.web.session.timeout is the shorter spelling of the same thing, and Boot's key wins. A value that cannot be read becomes 0 (no limit) rather than a guess.

An expired session is not a session: find(req) drops it and answers null, so the cookie the client is still holding names nothing and the next request that wants a session is given a new one (tests/programs/t181_session_timeout.teyru).

There is deliberately no background reaper. This runtime joins every thread when the program ends, so a reaper looping forever would stop a program from finishing; instead every request checks a few sessions — a cursor advances, eight per call — so the cost is spread over the traffic rather than handed to a reaper, and Sessions.prune() walks the whole store at once for a test or a quiet moment. Worth knowing: with no store default set, that cursor does nothing — a session that set only its own interval is found by find() or by prune().

Validation

lib/36_validation.teyru is the piece of bean validation a web layer needs: a class declares constraints on its own fields, and one call checks them.

annotationmeaning
@NotNullthe field has to hold something
@Size(min = …, max = …)a String field's length has to be within [min, max], both ends included
@Min(value = …) / @Max(value = …)the lower / upper bound of a numeric field

All four declare String message() default …, so an unwritten message has a default. Validation.check(bean) reads the object's own declared fields (through the same reflection the JSON binding reads), and the first field that violates a constraint throws ValidationException with the message field: message — Spring reports every violation at once, this reports the first, and a caller that wants the rest can ask again.

The web layer calls it on every object the binding built, so a request body that breaks a constraint of its own type is answered with a 400 whose body is bad request: field: message: the client made the mistake, and the answer says which field. A @Min/@Max on a field that is not a number, or a @Size on a field that is not a String, is a violation too, whose message says the constraint cannot read that field — the class is written wrong, but that must not crash the server. tests/programs/t160_validation.teyru covers this.

Those four names are taken. Teyru's simple names live in one flat namespace, so NotNull, Size, Min and Max are already the names of the constraint annotations and a program cannot use them for a type of its own: declare a class Size and @Size means that class instead, so the constraint stops being checked (silently).

Uploads (multipart)

HttpRequest.multipart(String name) answers the part a multipart/form-data request sent under that field name, as a MultipartFile: name, originalFilename, contentType, content (a request body is a String already, so a file arrives as the characters that were sent), plus isEmpty() and size(); it is null when the request carries no such part.

It is also null when the request is not multipart, when its Content-Type names no boundary, or when its body is not the multipart it claims to be — not an exception, because whether that is a 400 is the handler's decision. The urlencoded form is untouched and still binds through @RequestParam (see tests/programs/t161_multipart.teyru).

Compressed responses

Whether a response is compressed is the handler's decision: set HttpResponse.gzipBody to true, and the connection loop reads Accept-Encoding on the way out and compresses only when every one of these holds:

  • the request accepts gzip (gzip or *; gzip;q=0 is a refusal, not an offer);
  • the body is at least 1024 bytes — gzip's own header and trailer are 18 bytes, and a body that is already one packet is not made better by being one packet and a header;
  • the compressed body is actually shorter.

The compression itself is lib/44_zip.teyru (see docs/language.md §11). Content-Encoding: gzip is written only when the body really was compressed; whenever the handler asked for compression the response carries Vary: Accept-Encoding, whether or not this particular request got the compressed form — a cache must not hand a compressed body to a client that never said it could decode one. Content-Length is recomputed by the server from the (compressed) body as it sends it.

The client half is opt-in: HttpClientRequest.acceptGzip() is what sends Accept-Encoding: gzip, and an answer that says Content-Encoding: gzip is decoded for the caller while the headers — Content-Length among them — keep the numbers that were on the wire. A malformed gzip is not swallowed: ZipException/EOFException come out of send (tests/programs/t180_http_gzip.teyru).

Testing

MockServer asks the server without opening a socket:

MockServer server = new MockServer(ctx)
server.get("/pets")                                  // the body
server.request("POST", "/pets", body, "application/json")
server.handle(req)                                   // a request you built, for headers

The reason is the same one Spring has MockMvc: testing a route should not need a port, a client, or a second thread. server.handle(req) takes a request that is already prepared — call readCookies() yourself if it carries a cookie.

The server on a thread of its own

The accept loop is a Runnable too (ServerTask, in lib/18_web.teyru), so a client and a server fit in one program:

HttpServer server = new HttpServer(0, Application.routerFrom(ctx), ctx)
server.bind()                                  // bind first, so the port is known
ServerTask task = new ServerTask(server)
Thread serving = new Thread(task, "server")
serving.start()
// … send requests …
task.stop()
serving.join()

server.bind() has to come before the thread starts: the port (server.getPort()) is what the URLs are built from, and a listener that is already bound answers a connection that arrives while the serving thread is still on its way to accept. stop() asks the loop to finish between two connections and does not interrupt the one being answered, and isRunning() answers whether it is still going. A port of 0 asks the kernel for a free one. tests/programs/t162_http_roundtrip.teyru is exactly this: the server on a thread, the main thread as the client, a round trip inside one program.

Known limitations

  1. One connection at a time. The loop still answers one connection at a time, but it can run on a thread of its own now (the ServerTask above), so "a second connection waits for the first" is the shape of the loop, not of the program: a client and a server fit in one program. Serving several connections at once needs thread-per-connection, which is not there; the shape is already the shape it needs.
  2. Almost no content negotiation. Only the method's declared type is looked at, not Accept; the one exception is Accept-Encoding and gzip — and that needs the handler to turn gzipBody on first. brotli, deflate and the other codings are not there.
  3. Sessions live in the process. With two processes behind one address a request has to come back to the one that made the session, and the id is 128 bits of java.util.Random — enough for one server, not a cryptographic source.
  4. No SSE. Multipart uploads and validation annotations are both there, in the two sections above; server push is not.
  5. No @Conditional, @Import, @Lazy, AOP or transactions, and no scope control for @ComponentScan: the whole program is in scan scope, because the compiler sees everything — if you want to exclude something, just don't annotate it.

On this page