A CRUD address service with a mutex-locked repository
The complete source of webServices_addresses.ek9, compiled by the EK9 compiler. Shown on webServices.html.
#!ek9 defines module site.webServices.addresses 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 ? <?- 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!")