Web Services in EK9
This is the final section on constructs. The sections after it cover Common Design Patterns, packaging and the compiler command line arguments. Web services are deployed with, and by, Applications and Programs, as the introduction and structure sections show.
A web service can render static or dynamic websites, or be a microservice. Both use the HTTP class and the service construct.
In its simplest form a service serves file content. With text constructs you can implement simple templating (similar to velocity templates). EK9 web services also aim to support REST fully, and nothing stops you building a full web UI tool kit.
The examples on this page are:
- simple static HTML served from content in memory
- a web server that serves static file content
- a simple REST (RPC) CRUD microservice.
You can go beyond CRUD with GraphQL, HATEOAS with HAL/ATOM, or your own link content. This page shows the mechanisms and APIs built into EK9, and a simple CRUD application is enough for that.
The first two examples are short. The last is long: it deals with caching, etags and concurrency, and shows several language mechanisms (composition, and a blend of dynamic functions and classes).
Immutable software versus mutable configuration
You might argue that serving 'in memory' static content from an application is not viable in production, and that such configuration data belongs, in isolation, in databases and other 'stores'. But with 'docker' and automated CI/CD pipelines, deploying a fully tested, version-controlled microservice is as quick and controlled as updating configuration.
Automated deployments
This site, for example, is deployed automatically whenever the main repository builds successfully, as is the Javadoc. The deployment is full and complete (all the built artifacts) through a CI/CD pipeline.
An organisation with comprehensive CI/CD pipelines and automated tests can deploy services to live many times a day. Then a change is a new deployment, which is simple and quick.
As infrastructure has become 'immutable', so have applications. Unchanging configuration data and fixed information can be bound into the application.
Why EK9 does it this way. This is one of the main reasons EK9 has the text construct: it separates data from code, while still binding the data into a version-controlled release of the application. If you value 'caching', you already have 'immutable' configuration data, at least for the life of a cache entry.
If you prefer to put everything, immutable data included, in a database, you can do that with EK9 too. But arguably only truly mutable data belongs there. The CRUD example later should store its data in a resilient data store, not a simple in-memory model; it does not, because these examples are about web services, not storage.
It comes down to confidence in automated testing, the speed of builds, and how fast and automated deployment is. If we are honest, it often comes down to 'fear' and 'blame': some see a configuration change as less risky than a new software version. Sometimes it is more risky, because there are fewer controls on it.
With 'cloud formation' or 'pulumi'/'terraform' you could keep all of this in one source repository:
- the code for the application being deployed
- the 'AWS', 'Azure' or 'GCP' (etc.) terraform code
- all the 'Unit Tests'
- all the 'Component Tests'
- any stubs, fakes, mocks or mock services
- any 'web service' contracts
- one or more links to predefined configurations
Then full deployments can be automated. 'Secrets' (keys, passwords, certificates) still have to be externalised and provisioned separately. But development teams can run the whole service under development, in a wide range of scenarios.
The point for web services: they fit into a much wider solution, and are not a matter of 'developer' preference alone.
Verbs and HTTP Headers
EK9 web services centre on caching and on GET, POST, PUT, PATCH and DELETE. The server always checks for stale content (conditional requests), and checks an etag before a last modified date.
This page assumes you know HTTP and REST web services. If any of these terms are unfamiliar, read up on them first.
The Examples
The examples show only the service construct; the application and program are covered elsewhere.
Static HTML content
This example uses a text construct to return simple HTML. The content is served from site/index.html with the GET verb; this is the 'route' other frameworks talk about.
Note how it sets the etag, status, content, contentType and, importantly, the cacheControl. These matter: they move processing to clients and caching proxies, which improves performance at the cost of some data being less fresh than a full end-to-end call.
#!ek9 defines module site.webServices.staticHtml defines text for "en" WebSite index() `<html> <head> <meta charset="UTF-8"/> </head> <body> <p>Hello, World</p> </body> </html>` defines service Site for :/site open index() as GET for :/index.html <- response as HTTPResponse: () with trait HTTPResponse //Normally you'd use a component and inject it (i.e. a singleton with web site within) webSite <- WebSite("en") etag as String: String() override etag() <- rtn <- String() //Do this (:=?) so the etag is cached and not recalculated each time. //🔑 The body is hashed as OCTETS - no decode, no encoding assumption, and it works //for a binary body. This was an interim .toText("utf-8") round-trip until Digest gained //the Bytes overload; hashing a response body is the textbook digest use. etag :=? Digest().SHA256(content()).toText("hex") rtn := etag override cacheControl() as pure <- rtn as String: "public,max-age=3600,must-revalidate" override contentType() as pure <- rtn as String: "text/html" override contentLanguage() as pure <- rtn as String: "en" override content() <- rtn as Bytes: Bytes().withUtf8(webSite.index()) override status() as pure <- rtn as Integer: 200 default operator ?
Download webServices_static_html.ek9
What's being shown
Even for a simple HTML page, focus on HTTP technology and techniques: the protocol has a lot of capability. The EK9 service construct exists for one purpose: to be the place for that web-service code.
So the service construct is the place to deal with content negotiation, 'varies', 'mime types' and data formats. Most importantly, the response should always be a 'dynamic' class with the trait HTTPResponse. Then, if you use the etag or lastModified methods correctly, EK9 can avoid the hard work of producing content at all. If you also set cacheControl, your code may not even be called after the first time.
The EK9 HTTP server deals with the remote client (or, better, the proxy in front of the EK9 server). It calls your response like this:
- It gets only the etag or lastModified.
- It checks whether the caller sent the matching conditional headers.
- If so, it may answer 'not modified', and then content() is never called.
- Only when it must does it make the most expensive call, content(), which triggers your business processing (and may touch many objects, databases or other services).
It is a lazy evaluation that avoids the expensive call whenever it can. So EK9's web-service construct works more like a 'framework': you plug your code into pre-designed HTTP flows and EK9 APIs.
In the example above the code is trivial: it gets content from a text object in memory and encodes it once, as UTF-8, into the Bytes that content() returns. The open after the service's URI is its posture: who may call it (E12050 asks for one). With the cacheControl directives, a caching proxy such as squid would not call your service at all while its cached content is in date. Even when it is out of date, squid sends the etag in a request header, and the EK9 server answers 'not modified' if your code returns the same etag.
So it is important to have a quick, 'cheap' way to get a resource's etag or lastModified value. The HTTP protocol is very good, and it is often badly under-used.
A mini Web Server
A cut-down web server that serves text file content.
#!ek9 defines module site.webServices.fileServer defines service WebServer for :/website open documentRoot() as pure <- rtn as FileSystemPath: FileSystemPath(EnvVars().get("DOCUMENT_ROOT")) if not rtn? throw Exception("Invalid Document Root") //Now I've altered the dynamicVariableCapture so it is possible name parameters. //This means we can use expressions in the capture itself and hence can clean up the //expression. This small change means that we can remove dummy creation of responses. file() as GET for :/{fileName} -> fileName as String :=: PATH "fileName" <- response as HTTPResponse: ( webServer: this, fileName: fileName ) with trait of HTTPResponse lastModified as DateTime: DateTime() private lastModifiedOfTextFile() as pure <- rtn as DateTime: DateTime() file <- textFile() if not file.isReadable() Stdout().println(`${file} is not readable`) rtn := file.lastModified() private textFile() as pure <- rtn as TextFile: TextFile(webServer.documentRoot() + FileSystemPath(fileName)) override lastModified() as pure <- rtn as DateTime: DateTime() lastModified :=? lastModifiedOfTextFile() rtn := lastModified override cacheControl() as pure <- rtn as String: "public,max-age=3600,must-revalidate" override contentType() as pure <- rtn as String: "text/html" override contentLanguage() as pure <- rtn as String: "en" override status() as pure <- rtn as Integer: lastModified? <- 200:404 override content() <- rtn as Bytes: Bytes() if lastModified? //Collect the text, then encode ONCE at a visible point. There is deliberately no //Bytes '|' that takes a String: piping text straight into octets would have to //assume an encoding, which is the bug this whole change removes. html <- String() cat textFile().input() > html rtn: Bytes().withUtf8(html) default operator ?
Download webServices_file_server.ek9
Points of interest:
- The directory files are served from comes from an environment variable (DOCUMENT_ROOT).
- The service WebServer serves from 'website/'.
- Its file() operation takes the path variable 'fileName' from the URI.
- The anonymous dynamic class captures the WebServer instance and the fileName.
- This service uses lastModified rather than etag for caching.
- The caching directive public,max-age=3600,must-revalidate offloads high-volume calls.
- The anonymous dynamic class has utility methods: lastModifiedOfTextFile()/textFile().
- Standard EK9 file handling locates the file.
- A stream pipeline reads the file's contents into a String, which is then encoded once (as UTF-8) into the Bytes returned.
- The HTTP status is 200 or 404, depending on whether lastModified is set.
Why do the processing inside a dynamic class with the trait HTTPResponse?
- Delayed processing, as described above.
- Thread safety: many client calls come into the file() method of the one 'WebServer' instance.
- Each response can hold its own state (lastModified/fileName, for example).
- Common parts of a response can be composed, as later examples show.
- An HTTPResponse is short-lived, and serves only the client that initiated it.
A CRUD type Server
This example is much longer. It is a repository of postal Addresses, and includes the definition of the Address record and its marshalling to and from JSON (EK9 also has a JSON type you can use).
It covers etags and the HTTP verbs POST (C), GET (R), PUT (U), DELETE (D) and PATCH (merge). It also deals with concurrency through a mutex lock: it takes a collection that is not safe for concurrent use and makes it safe for many threads to access.
The code blends functional and object-oriented approaches, with a focus on composition, which it uses extensively to create the HTTPResponse.
- Some web service 'methods' are written 'long hand', so the processing is obvious. Where the processing is very similar, extra classes and functions are composed in different ways instead.
- The HTTP response codes ('404' and so on) are left as they are, not hidden or made constants. '404' could have been a constant NOT_FOUND, but here that would only add length.
The internal model
The first part (the bulk) sets up the data structures and constructs the example needs. The second part is the web service itself.
defines type //Just for strong typing - no constraints AddressId as String defines record Address id <- AddressId() street <- String() street2 <- String() city <- String() state <- String() zipcode <- String() Address() -> id as AddressId street as String street2 as String city as String state as String zipcode as String this.id :=: id this.street :=: street this.street2 :=: street2 this.city :=: city this.state :=: state this.zipcode :=: zipcode Address() -> from as Address this :=: from Address() -> from as Optional of Address if from? this :=: from.get() //Copy operator :=: -> from as Address id :=: from.id street :=: from.street street2 :=: from.street2 city :=: from.city state :=: from.state zipcode :=: from.zipcode //Merge only if incoming address parts are set operator :~: -> address as Address if address.street? street :=: address.street if address.street2? street2 :=: address.street2 if address.city? city :=: address.city if address.state? state :=: address.state if address.zipcode? zipcode :=: address.zipcode override operator ? as pure //street2 is optional and can be omitted <- rtn as Boolean: id? and street? and city? and state? and zipcode? defines text for "en" AddressToOutputFormat toJSON() -> address as Address `{ "address": { "id": "${address.id}", "street": "${address.street}", ${optionalJSON("street2", address.street2)} "city": "${address.city}", "state": "${address.state}", "zipcode": "${address.zipcode}" } }` defines function //Used for specific operations to be applied to a set of addresses addressOperation() as abstract -> addresses as AddressAccess address as Address //deals with wrapping the operation in the calls to deal with mutex lock. safeOperation() -> lockedAddressSet as MutexLock of AddressSet address as Address operation as addressOperation accessKey <- (address, operation) extends MutexKey of AddressSet as function operation(lockedItem, address) require lockedAddressSet.enter(accessKey) addressFromJson() -> addressInJSONFormat as String <- rtn as Address: Address() addressParts <- addressDictionaryFromJSON(addressInJSONFormat) rtn.id: AddressId(addressParts.getOrDefault("id", "")) rtn.street: String(addressParts.getOrDefault("street", "")) rtn.street2: String(addressParts.getOrDefault("street2", "")) rtn.city: String(addressParts.getOrDefault("city", "")) rtn.state: String(addressParts.getOrDefault("state", "")) rtn.zipcode: String(addressParts.getOrDefault("zipcode", "")) <?- Until we have a more general JSON parser a bespoke parser is needed for this demonstration -?> addressDictionaryFromJSON() -> json as String <- rtn <- Dict() of (String, String) //Just pull out the address bits from within {}'s using a regex extractAddressPartsEx <- /\{\s+"address":\s+\{\s+([^}]*?)\s+\}\s+\}/ //Now break into lines based on commas addressItems <- json.split(extractAddressPartsEx).getOrDefault(0, "").split(/,/) //Map to a dictionary and return. rtn: cat addressItems | map with toDictEntry | collect as Dict of (String, String) toDictEntry() -> line as String <- rtn as DictEntry of (String, String)? keyValues <- line.trim().split(/:/) rtn: DictEntry(keyValues.getOrDefault(0, "").trim().trim('"'), keyValues.getOrDefault(length keyValues - 1, "").trim().trim('"')) copyAddress() -> from as Address <- to as Address: Address(from) wrapInBrackets() -> mainValue as String <- rtn as String: `[ ${mainValue} ]` commaSeparated() -> firstPart String secondPart String <- rtn as String: firstPart? and secondPart? <- `${firstPart},${secondPart}` : String() addressToJSON() -> address as Address <- addressAsString as String: AddressToOutputFormat("en").toJSON(address) addressListToJSON() -> addresses as List of Address <- listAsString as String: cat addresses | map with addressToJSON | join with commaSeparated | map with wrapInBrackets | collect as String optionalJSON() as pure -> name as String mainValue as String <- rtn as String: mainValue? <- `"${name}": "${mainValue}",` else String() defines trait AddressAccess hash() <- rtn as String: String() hashOfAddress() -> id as AddressId <- rtn as String? require id? rtn: String() byId() -> id as AddressId <- rtn as Address? listAll() <- rtn as List of Address: List() of Address operator += -> address as Address require address? operator -= -> address as Address require address? //merge with an existing address operator :~: -> address as Address require address? //replace an existing address operator :^: -> address as Address require address? operator contains as pure -> addressId as AddressId <- rtn as Boolean: Boolean() defines class AddressSet with trait of AddressAccess hash as String: Digest().SHA256(GUID()) addresses <- Dict() of (AddressId, Address) hashes <- Dict() of (AddressId, String) override hash() <- rtn as String: this.hash override hashOfAddress() -> id as AddressId <- rtn as String: String(hashes.getOrDefault(id, "")) override byId() -> id as AddressId <- rtn as Address: Address(addresses.getOrDefault(id, Address())) override listAll() <- rtn <- List() of Address iter <- addresses.values() cat iter | map with copyAddress > rtn private includeAddress() -> address as Address copy <- Address(address) addresses += DictEntry(copy.id, copy) hashes += DictEntry(copy.id, Digest().SHA256(addressToJSON(copy))) updateHash() private updateHash() hash :=: Digest().SHA256(GUID()) override operator += -> address as Address require address? if this not contains address includeAddress(address) override operator -= -> address as Address require address.id? addresses -= address.id hashes -= address.id updateHash() override operator :~: -> address as Address require address.id? //We don't assert whole address because it can be partial currentAddress <- addresses.getOrDefault(address.id, Address()) if currentAddress? //make a new copy and then merge the two. updatedAddress <- Address(currentAddress) updatedAddress :~: address includeAddress(updatedAddress) override operator :^: -> address as Address require address? if this contains address includeAddress(address) operator contains as pure -> address as Address <- rtn as Boolean: this contains address.id override operator contains as pure -> addressId as AddressId <- rtn as Boolean: addresses contains addressId default operator ? //Example of wrapping shared data set in a mutex lock. LockableAddressSet with trait of AddressAccess lockedAddressSet as MutexLock of AddressSet: MutexLock(AddressSet()) override hash() <- rtn as String: String() accessKey <- (rtn) is MutexKey of AddressSet as function rtn :=: lockedItem.hash() require lockedAddressSet.enter(accessKey) override hashOfAddress() -> id as AddressId <- rtn as String: String() accessKey <- (id, rtn) is MutexKey of AddressSet as function rtn :=: lockedItem.hashOfAddress(id) require lockedAddressSet.enter(accessKey) override byId() -> id as AddressId <- rtn as Address: Address() accessKey <- (id, rtn) is MutexKey of AddressSet as function rtn :=: lockedItem.byId(id) require lockedAddressSet.enter(accessKey) override listAll() <- rtn <- List() of Address accessKey <- (rtn) is MutexKey of AddressSet as function rtn += lockedItem.listAll() require lockedAddressSet.enter(accessKey) override operator += -> address as Address //You can inline this simple dynamic function if you wish //Also used named parameters safeOperation( lockedAddressSet: lockedAddressSet, address: address, operation: () is addressOperation (addresses += address) ) override operator -= -> address as Address //Or you can inline all on one line. safeOperation(lockedAddressSet, address, () is addressOperation (addresses -= address)) override operator :~: -> address as Address //Or define a dynamic function and pass in as delegate (my preferred way) operation <- () is addressOperation addresses :~: address safeOperation(lockedAddressSet, address, operation) override operator :^: -> address as Address operation <- () is addressOperation addresses :^: address safeOperation(lockedAddressSet, address, operation) override operator contains as pure -> addressId as AddressId <- rtn as Boolean: false accessKey <- (addressId, rtn) is MutexKey of AddressSet as function rtn :=: lockedItem contains addressId require lockedAddressSet.enter(accessKey) default operator ? defines function plainNonCacheableHTTPResponse() <- rtn as HTTPResponse: () with trait of HTTPResponse override cacheControl() as pure <- rtn as String: "no-store,max-age=0" override contentType() as pure <- rtn as String: "text/plain" override contentLanguage() as pure <- rtn as String: "en" default operator ? cacheableHTTPResponse() <- rtn as HTTPResponse: () with trait of HTTPResponse override cacheControl() as pure <- rtn as String: "public,max-age=5,must-revalidate" override contentType() as pure <- rtn as String: "application/json" override contentLanguage() as pure <- rtn as String: "en" default operator ? defines class ByETagHTTPResponse with trait of HTTPResponse by delegate repository as Repository! delegate as HTTPResponse? addressId as AddressId? provideContentLocation as Boolean? status <- Integer() etagOfAddress <- String() default private ByETagHTTPResponse() ByETagHTTPResponse() -> addressId as AddressId delegate as HTTPResponse provideContentLocation as Boolean require addressId? and delegate? and provideContentLocation? this.addressId: addressId this.provideContentLocation: provideContentLocation this.delegate: delegate ByETagHTTPResponse() -> addressId as AddressId delegate as HTTPResponse this(addressId, delegate, true) override etag() <- rtn as String: String() //Only call if un-set. etagOfAddress :=? repository.addresses().hashOfAddress(addressId) rtn := etagOfAddress status :=? ~etagOfAddress? <- 404 else 200 status() -> newStatus as Integer status :=: newStatus override status() as pure <- rtn as Integer: status override contentLocation() as pure <- rtn as String: String() httpBadRequest <- 400 rtn: status < httpBadRequest and provideContentLocation <- `/addresses/${addressId}` else String() default operator ?
Full source: webServices_addresses.ek9 - Download webServices_addresses.ek9
These constructs hold a set of Addresses (a record) in memory, and limit access to them with a mutex lock, using dynamic functions as delegates.
Pulling AddressAccess out into a trait construct allows two implementations: the actual address storage, and a simple thread-safe wrapper.
The final part is the service definition, which uses the caching HTTPResponse implementations.
<?- REST web service. -?> defines service //The Name of the and the uri it is mapped to Addresses for :/addresses open //It is still possible to use dynamic variable capture, like this but see the later code. //It used named variables. byId() as GET for :/{address-id} -> id as String :=: PATH "address-id" //required because different name <- response as HTTPResponse? addressId <- AddressId(id) delegate <- ByETagHTTPResponse(addressId, cacheableHTTPResponse(), false) response: (addressId, delegate) with trait of HTTPResponse by delegate repository as Repository! override content() <- rtn as Bytes: Bytes() httpNotFound <- 404 if delegate.status() <> httpNotFound rtn: Bytes().withUtf8(addressToJSON(repository.addresses().byId(addressId))) default operator ? //A POST operator += for :/ -> request as HTTPRequest :=: REQUEST <- response as HTTPResponse: ( request: request, nonCacheable: plainNonCacheableHTTPResponse() ) with trait of HTTPResponse by nonCacheable repository as Repository! address as Address: Address() status as Integer: 201 override content() <- rtn as Bytes: Bytes() address: addressFromJson(request.content().toText("utf-8")) //But the server sets the ID! if address.id? status := 422 //unprocessable entity rtn: Bytes().withUtf8("Do not supply ID in Address, server will set this") else address.id: AddressId(GUID()) if ~address? status := 422 //unprocessable entity else if repository.addresses() contains address.id status := 409 //conflict else repository.addresses() += address override contentLocation() as pure <- rtn as String: String() httpCreated <- 201 rtn: status == httpCreated <- `/addresses/${address.id}` else String() override status() as pure <- rtn as Integer: status default operator ? //A DELETE operator -= for :/{id} -> id as String //Assume PATH <- response as HTTPResponse: ( addressId: AddressId(id), delegate: ByETagHTTPResponse( AddressId(id), plainNonCacheableHTTPResponse(), false ) ) with trait of HTTPResponse by delegate repository as Repository! override content() <- rtn as Bytes: Bytes() httpNotFound <- 404 //Only if the etag was found can we delete it! if delegate.status() <> httpNotFound repository.addresses() -= repository.addresses().byId(addressId) delegate.status(204) default operator ? //A PATCH which is a merge //Note with dynamic variable capture, you can still do it all one one line if you wish. operator :~: for :/{id} -> id as String //Assume PATH incomingContent as String :=: CONTENT <- response as HTTPResponse: ( addressId: AddressId(id), incomingContent: incomingContent, delegate: ByETagHTTPResponse(AddressId(id), plainNonCacheableHTTPResponse()) ) trait HTTPResponse by delegate repository as Repository! override content() <- rtn as Bytes: Bytes() httpNotFound <- 404 if delegate.status() <> httpNotFound address <- addressFromJson(incomingContent) if ~address.id? delegate.status(422) //unprocessable entity else if address.id <> addressId delegate.status(400) //the id on the url is not the same as the id in the body content else repository.addresses() :~: address delegate.status(204) default operator ? //A PUT which is a replace for an existing address //Now the dynamic variable capture allowed multiple lines and various formatting within the () operator :^: for :/{id} -> id as String //Assume PATH content as String :=: CONTENT <- response as HTTPResponse: ( addressId: AddressId(id), incomingContent: content, delegate: ByETagHTTPResponse(AddressId(id), plainNonCacheableHTTPResponse()) ) trait HTTPResponse by delegate repository as Repository! override content() <- rtn as Bytes: Bytes() httpNotFound <- 404 if delegate.status() <> httpNotFound address <- addressFromJson(incomingContent) if ~address? delegate.status(422) //unprocessable entity else if address.id <> addressId delegate.status(400) //the id on the url is not the same as the mainValue in the body content else repository.addresses() :^: address delegate.status(204) default operator ? //Note it is now possible to use named parameters in dynamic variable capture //So now, we can do a simpler one liner, define the name of the property and use an expression //to set it up, then use the 'by' on a trait to delegate as much or as little as we want to it. listAll() for :/ <- response as HTTPResponse: ( cacheable: cacheableHTTPResponse() ) with trait of HTTPResponse by cacheable repository as Repository! override etag() <- rtn as String: repository.addresses().hash() override content() <- rtn as Bytes: Bytes().withUtf8(addressListToJSON(repository.addresses().listAll())) default operator ? defines component Repository as abstract addresses() as pure abstract <- rtn as AddressAccess? default operator ? InMemoryRepository extends Repository addresses as AddressAccess: LockableAddressSet() InMemoryRepository() addresses += Address(id: AddressId(GUID()), street: "121 Admin Rd.", street2: String(), city: "Concord", state: "NH", zipcode: "03301") addresses += Address(id: AddressId(GUID()), street: "67 Paperwork Ave", street2: String(), city: "Manchester", state: "NH", zipcode: "03101") addresses += Address(id: AddressId(GUID()), street: "15 Rose St", street2: "Apt. B-1", city: "Concord", state: "NH", zipcode: "03301") addresses += Address(id: AddressId(GUID()), street: "39 Sole St.", street2: String(), city: "Concord", state: "NH", zipcode: "03301") addresses += Address(id: AddressId(GUID()), street: "99 Mountain Rd.", street2: String(), city: "Concord", state: "NH", zipcode: "03301") override addresses() as pure <- rtn as AddressAccess: addresses default operator ? defines application //SERVING wiring: this application registers the REST service, so any program using //it MUST create an HTTP instance. EK9 enforces that both ways — a program wiring this //application without HTTP is E12020 (the service could never receive a request), and //an HTTP program wired to an application with no services is E12021. AccessPoint register InMemoryRepository() as Repository register Addresses() //COMPONENT wiring only: no service is registered here, so a plain (non-serving) //program can use it. Splitting the two is the point — conflating them is what //leaves a REST service registered and permanently unreachable. RepositoryOnly register InMemoryRepository() as Repository defines program //Uses the component-only application: it exercises the repository and exits, so it //must NOT be wired to the service-registering AccessPoint. TestAddressOutput with application of RepositoryOnly //Expect injection repository as Repository! stdout <- Stdout() address1 <- Address(id: AddressId(GUID()), street: "121 Admin Rd.", street2: String(), city: "Concord", state: "NH", zipcode: "03301") address2 <- Address(id: AddressId(GUID()), street: "15 Rose St", street2: "Apt. B-1", city: "Concord", state: "NH", zipcode: "03301") addresses <- [address1, address2] iter <- addresses.iterator() //convert contents in iterator to a list theAddresses <- cat iter | collect as List of Address require theAddresses? stdout.println("Address as JSON") stdout.println(addressToJSON(address1)) jsonAddress <- addressFromJson(addressToJSON(address1)) stdout.println(`Rebuilt address is [${addressToJSON(jsonAddress)}]`) stdout.println("Now arrays") stdout.println(addressListToJSON(addresses)) stdout.println("Done basics now all addresses") allAddresses <- repository.addresses().listAll() stdout.println(`Hash of all addresses is [${repository.addresses().hash()}]`) stdout.println(addressListToJSON(allAddresses)) stdout.println("Done!")
Full source: webServices_addresses.ek9 - Download webServices_addresses.ek9
That is a long example with a lot to take in. It shows the role of the 'service' construct, and how important the mutex locks and the 'injection' of 'components' are.
Note the two applications. 'AccessPoint' registers the 'Addresses' service, so a program using it must start an HTTP server, or the service could never receive a request (E12020). 'RepositoryOnly' registers only the component, so 'TestAddressOutput' can exercise the repository and exit.
Parameter Binding Types
EK9 services have six parameter binding types. Each maps part of the HTTP request to a service operation
parameter, with the :=: qualifier syntax:
- PATH — from a URI path variable, e.g.
id as String :=: PATH "item-id". The default when there is no qualifier. - QUERY — from a URL query parameter, e.g.
term as String :=: QUERY "q" - HEADER — from an HTTP header, e.g.
auth as String :=: HEADER "Authorization" - CONTENT — the request body as a String, e.g.
body as String :=: CONTENT - REQUEST — the whole
HTTPRequestobject. It must be the only parameter, e.g.request as HTTPRequest :=: REQUEST - CONTEXT — the
HTTPContextobject with the request's metadata: authenticated principal, security state, connection information and request identity. It must be the only parameter, e.g.ctx as HTTPContext :=: CONTEXT
- QUERY and HEADER need a source qualifier string, the name to read (E07710).
- PATH may take one; without it, the parameter matches the URI template variable of the same name
(
id as String :=: PATH). - CONTENT, REQUEST and CONTEXT take no qualifier (E07720).
- A REQUEST or CONTEXT parameter must be the only parameter of the operation (E07780, E07792).
The HTTPContext trait provides:
principal()— the authenticated user identityisAuthenticated()— whether the request is authenticatedisSecure()— whether the connection uses HTTPS/TLSremoteAddress()— the client's IP addressrequestId()— a unique identifier for the request
Operational Configuration
The program configures the server when it creates it: HTTP(properties) takes a
NetworkProperties value, and binds its port straight away. In keeping with
immutable software, have the program read those values from its environment (with EnvVars);
then the same binary runs unchanged in development, staging and production, and only the environment differs.
The server takes these settings. Every one is optional, so a server can be started with none at all:
| Setting | Default | Purpose |
|---|---|---|
port (NetworkProperties) |
8080 |
TCP port the server listens on; one port per HTTP instance. 0 lets the
operating system choose, and the chosen port is written back into the NetworkProperties. |
host (NetworkProperties) |
0.0.0.0 |
Network interface address to bind. |
localOnly (NetworkProperties) |
(unset, so false) | true binds the loopback address only. |
backlog (NetworkProperties) |
0 |
Socket accept-queue depth. 0 means use the operating-system default. |
EK9_HTTP_MAX_BODY_SIZE (environment) |
10485760 |
Maximum size in bytes of a request body (10 MB). |
EK9_HTTP_TRUSTED_PROXY_CIDR (environment) |
(none) | The address range of trusted proxies. An X-Forwarded-For header is believed only from a
proxy in this range; otherwise it is ignored and remoteAddress() is the direct peer. |
DOCUMENT_ROOT (environment) |
(none) | Read by the mini Web Server example above, not by the server. It is application data, not HTTP server configuration. |
The two environment variables are read once, when the server is created. When serving stops, in-flight requests have up to five seconds to finish.
What is deliberately not configurable
Settings other HTTP frameworks expose are intentionally omitted:
- TLS / HTTPS termination — terminate TLS in your ingress controller, reverse proxy or load balancer (nginx, HAProxy, Envoy, Kubernetes ingress, AWS ALB, etc.). Mutual TLS, certificate rotation and modern cipher-suite policy are a full-time job for specialist infrastructure, not a language feature.
- Multi-port binding — an
HTTPinstance binds exactly one port. If you need several entry points, run several processes; that is what container orchestrators are for. - Per-route configuration — caching headers, rate limits and access control belong either in the
service operation itself (through
HTTPResponse) or in the layer in front of it. - Thread-pool sizing — each request runs on its own virtual thread, so there is no pool to tune.
- Dynamic reconfiguration — the settings are fixed when the server is created. Change them by deploying a new process with a new environment. That is the point of immutable software.
Anything not in the table above is, by design, the job of the layer in front of the EK9 service. The next section explains why.
Software development versus solution deployment
Once a web service is deployed, it is part of a wider solution: caching proxies, networks and operations, and the people and organisations who run them. Its function, performance and reliability are visible beyond the developers who wrote it. Design note: software development versus solution deployment is a more general observation about that, which you may or may not agree with.
Networking/Caching
If a fast cache can supply your content more cheaply, at a higher volume of concurrent calls, and reasonably current, use it. Sometimes you can't; when you can, do.
If you accept that users will sometimes see stale information, something like nginx, squid or AWS 'API gateway' can take the load off your application. Take care with your responses and 'varies'.
If you depend on caching to save load, there is a downside. When your systems restart and the caches are 'cold', every request goes to your origin server (your code), which suddenly gets the full force of what the cache was saving it from, and is likely to fall over.
- Have operational procedures that throttle the initial load after a restart, so the caches can warm up.
- Or keep the system offline from its full load, and warm the caches with your own scripted calls.
'Devops' means something: if you develop code and also operate it, move out of your comfort zone and embrace 'operations', 'infrastructure', 'networking' and appliances such as caching software.
Why EK9 does it this way. That is why the web service examples and HTTPResponse look verbose. EK9 does not hide the HTTP protocol; it draws it out, so you can master it and build sophisticated, performant solutions. The examples could have been little 'Hello, World' services, so I could claim a web service in ten lines of EK9. That would miss the point: web services add a lot of function and a lot of complexity. Look at how many HTTP headers, response codes and 'verbs' there are.
Summary
The HTTP server built into EK9 is not designed to be the most configurable or the most flexible. It is designed to get out of the way, so you can focus on the services you offer, the data formats they need and caching.
- High performance: use caching, so the origin server is hardly called. Even with dynamic data and short cache lifetimes this works, with must-revalidate.
- Scaling when the code must be called: run multiple instances with docker and Kubernetes.
- Command Query Responsibility Segregation adds complexity, but lets 'read' requests scale through 'replicas'. It needs either very fast data replication, or acceptance of short-term inconsistency from the client's point of view.
You cannot do web services well without focusing on caching, HTTP response codes and concurrent access to data. Web services also fit into a wider infrastructure, whose components enable, or require, certain behaviour from software components.
Next Steps
Common Design Patterns covers some common patterns of use. For the command line parameters, see the command line section.