There are two basic building blocks in Ethel - a client and an endpoint. A client, implemented by WSClient
, has one essential job - to create and execute endpoints. An endpoint, implemented by TWSEndpoint
trait, is used to encapsulate one or more logical structures of an API. You interact with the client by deriving an endpoint of interest and executing it.
When a client executes an endpoint, it first creates and configures an http transport, ZnClient
, and then passes it to the endpoint for further configuration, before finally executing the resulting http request. This means that a client typically manages data that is common to all endpoints. While an endpoint manages data that is specific to a particular logical construct - i.e. parameters for querying or creating a resource.
Let’s take a look at each building block in more detail...
WSClient
manages only two bits of data: the base URL, via #baseUrl
, and a block for configuring http transport, via #httpConfiguration:
.
The base URL is used to derive the full request URL when executing an endpoint, with the endpoint providing a path relative to the base URL. So, in a sense - the base URL is a common root to all the endpoints. When subclassing, however, you could take care of the base URL à la specialized instance creators:
WSClient subclass: #AcmeClient
slots: { }
classVariables: { SandboxURL ProductionURL }
package: 'Acme'
WSClient class>>#initialize
ProductionURL := 'http://example.com/api'.
SandboxURL := 'http://sandbox.example.com/api'
WSClient class>>sandbox
^ self withUrl: SandboxURL
WSClient class>>production
^ self withUrl: ProductionURL
The http configuration block is optional and acts as a hook for configuring the request. It is mostly useful when scripting, or when creating instances with specialized configurations. You can see an example in WSClient class>>#jsonWithUrl:
, where this block is used to configure ZnClient
with a NeoJSON
content reader and writer.
When subclassing, it is better to use #configureOn:
method, rather than use the pluggable http configuration block. This is where it would make sense to set any kind authentication headers, user-agent strings, or anything that’s common to all endpoints. For configurations that are specific to endpoints - it’s best to use the endpoint's #configureOn:
.
The endpoint behavior is implemented in TWSEndpoint
trait. So any object could be an endpoint.
Object subclass: #AcmeThingsEndpoint
uses: TWSEndpoint
slots: { }
classVariables: { }
package: 'Acme'
Endpoint class must define #endpointPath
:
AcmeThingsEndpoint class>>#endpointPath
^ Path / #things
Endpoints are instantiated with a client object, and capture it via #wsClient
ivar.
client := AcmeClient sandbox.
client / AcmeThingsEndpoint.
client / #things.
AcmeThingsEndpoint on: client.
Customarily, the client, as well as endpoints, would provide methods for deriving other endpoints, thus establishing logical relationships between them.
AcmeClient>>things
^ self / AcmeThingsEndpoint
Endpoints should encapsulate some logical structure represented by the web service. It is up to you to define what these structure are and how you’d like to interface with them. But for a simple example, let’s say a search endpoint would be defined as follows:
Object subclass: #AcmeSearchEndpoint
uses: TWSEndpoint
slots: { #query }
package: 'Acme'
AcmeSearchEndpoint class>>endpointPath
"/things/search"
^ AcmeThingsEndpoint endpointPath / #search
AcmeSearchEndpoint>>search: aString
<get>
query := aString.
^ self execute
AcmeSearchEndpoint>>configureOn: http
http request queryAt: #query put: query
Notice the separation b/w data management and request execution. Here, we use an ivar to store the query, but actually configure the request with the query parameter in #configureOn:
. To execute an endpoint - simply call #execute
, as seen in AcmeSearchEndpoint>>#search
. Let's call this kind of method an executing method, meaning - it begins request execution.
Let’s look at how requests are executed...
Requests are actually executed by the client object, but the process usually starts with the endpoint, as in:
(client / AcmeSearchEndpoint) search: 'meaning of life'
Let's look at the implementing method:
AcmeSearchEndpoint>>search: aString
<get>
query := aString.
^ self execute
You’ll notice two things. First, is that it returns the result of self execute
, which simply calls: wsClient execute: self
. Second, is the <get>
pragma. It is this pragma that is used to designate the executing method. The value of this pragma is also used to configure the http request method, in this case GET. List of the recognized HTTP methods is defined in WSClient class>>supportedHttpMethods
, and can be changed, affecting identification of executing methods.
Another way to execute request is via TWSEndpoint>>#execute:
method:
AcmeSearchEndpoint>>search: aString
<get>
^ self execute: [ :http | http request queryAt: #query put: aString ]
This way we can skip the instance variable altogether. The argument block is the last thing that gets to configure the http transport before its request is executed, and therefore happens after callign #configureOn:
. Notice, we still designate the method with the <get>
pragma, even though we could easily override that inside the execution block.
Now, once the request is configured, it is validated (#validateRequest:
), executed (by calling ZnClient>>#execute
), and the response is validated (#validateResponse:
) before the result is returned. Use those validation methods to handle misconfigured requests and erroneous responses.
During the execution phase, the executing method sets the execution context. This context, as previously seen, contains both the HTTP method and the final URL for the request. The former is handled via pragmas, like <get>
, <post>
, etc. The latter, however, requires a bit of explanation.
Let's try to simplify our interface a bit by getting rid of the AcmeSearchEndpoint
altogether, replacing it with:
AcmeThingsEndpoint>>search: aString
<path: 'search'>
<get>
^ self execute: [ :http | http queryAt: #query put: aString ].
During execution, the value of the <path>
pragma, if present, will be resolved against the class-side #endpointPath
. Without the <path>
pragma - the instance-side #endpointPath
is used, which, by default, returns the class-side value. This path resolution happens between the client's and the endpoint's #configureOn:
calls.
It should be clear that the newly added method will result in a GET request to /things/search, resolved by appending relative path from <path>
to AcmeThingsEndpoint class>>#endpointPath
. Had we defined an absolute path, e.g. <path: '/search'>
, the resulting URL would have been /search.
Paths can also use format strings. For example:
AcmeThingsEndpoint>>at: aThingId
<path: '{aThingId}’>
<get>
^ self execute.
client things at: ‘idOfSomeThing'.
In this case, calling #at:
will result in a GET /things/idOfSomeThing. The string format is identical to String>>format:
, and the variables are sourced from the execution context of the executing method. And since path resolution happens at execution time, both #endpointPath
methods and the <path:>
pragma can make use of string formatting.
It is worth noting that the use of pragmas is mainly for organizational purpose. We could easily provide complete request configuration inside an execution block passed to #execute:
. So, to put things into perspective: mark executing methods with an HTTP method pragma, like <get>
, as that denotes the execution context. The fact that the framework configures HTTP request with a corresponding HTTP method is a convenience. The <path>
pragma is used for tracking which method handles the corresponding path. The fact that the request is configured with the resuling URL is, again, a convenience.
Lastly, in the event that /things/{thingId} represents a Thing with a lot of behavior, we could define it as a separate endpoint:
Object subclass: #AcmeThingEndpoint
uses: TWSEndpoint
slots: { #thingId }
package: 'Acme'
AcmeThingsEndpoint>>withId: aThingId
^ (self / AcmeThingEndpoint)
thingId: aThingId;
yourself
AcmeThingEndpoint class>>#endpointPath
^ AcmeThingsEndpoint endpointPath / '{thingId}'
AcmeThingEndpoint>>thingId
^ thingId
AcmeThingEndpoint>>thingId: anObject
thingId := anObject
AcmeThingEndpoint>>value
<get>
^ self execute
AcmeThingEndpoint>>updateWith: anUpdatedThing
<post>
^ self execute: [ :http | http contents: anUpdatedThing ]
Your interface with /things now looks like:
aThing := (client things withId: ‘someId') value.
aThing title: 'New title’.
aThing := (client things withId: aThing id) updateWith: aThing.
Or better yet,
AcmeClient>>thingWithId: aThingId
^ self things withId: aThingId
thingEp := client thingWithId: 'someId'.
thingEp value in: [ :aThing |
aThing title: 'New Title'.
thingEp updateWith: aThing ]
Another thing worth mentioning here is the ability of an endpoint to pass its state to another when using #/
to derive endpoints. For example, if we were to define another endpoint that makes use of the thingId
:
Object subclass: #AcmeThingSiblingsEndpoint
uses: TWSEndpoint;
slots: { #thingId };
package: ‘Acme’
AcmeThingSiblingsEndpoint class>>endpointPath
^ AcmeThingEndpoint endpointPath / #siblings
AcmeThingSiblingsEndpoint>>thingId: anObject
thingId := anObject
AcmeThingEndpoint>>configureDerivedEndpoint: anotherEndpoint
(anotherEndpoint respondsTo: #thingId:) ifTrue: [ anotherEndpoint thingId: self thingId ]
AcmeThingEndpoint>>siblings
^ self / AcmeThingSiblingsEndpoint
(client thingWithId: 'someId’) siblings.
(client thingWithId: 'someId’) / #siblings.
The last two lines would produce an identically configured instance of AcmeThingSiblingsEndpoint
.
When listing large collections, web services usually provide some sort of pagination mechanism, often in the form of: offset & limit, page & pageSize, or some sort of a cursors. Eventually that translates to some attributes in the HTTP request. It would be nice if we could interact with this API as we do with normal collections in Smalltalk.
This is where two additional structs come in: TWSEnumeration
and TWSCursor
traits. They allow one to easily setup an endpoint for enumeration. Let’s say that our /things endpoint allows enumeration using #page
and #page_size
query parameters.
Object subclass: #AcmeThingsEndpoint
uses: TWSEndpoint + TWSEnumeration
slots: { }
classVariables: { }
package: 'Acme'
We need to implement just two methods: #cursor
and #next:with:
. The former needs to return an instance that uses TWSCursor
, so let’s start there:
Object subclass: #AcmePaginationCursor
uses: TWSCursor
slots: { #page. #pageSize. #hasMore }
package: 'Acme'
AcmePaginationCursor>>initialize
super initialize.
page := 1.
pageSize := 100.
hasMore := true
Create accessors for the ivars, so that we can read/write cursor values. Now, to implement the required methods:
AcmeThingsEndpoint>>cursor
^ AcmePaginationCursor new
AcmeThingsEndpoint>>#next: aLimit with: aCursor
| result |
result := self execute: [ :http |
http request
queryAt: #page put: aCursor page;
queryAt: #’page_size’ put: aCursor pageSize.
].
(result size < aCursor pageSize)
ifTrue: [ aCursor hasMore: false ]
ifFalse: [ aCursor page: aCursor page + 1 ]
^ result
An enumerating endpoint behaves similar to a collection:
client things select: #title.
client first: 100.
client select: [ :each | each isInteresting ].
client select: [ :each | each isInteresting ] max: 100.
client detect: [ :each | each isInteresting ] ifFound: [:found | found title ] ifNone: [ nil ].
“etc"
Whenever you call any of the enumerating methods, the endpoint will acquire a new instance of the cursor, via #cursor
, and then call #next:with:
until the cursor answers negatively to #hasMore
, passing into it an optional limit and the running cursor as the two arguments. So, in our declaration of #next:with:
we first configure the endpoint using the cursor data, then call an executing method, then updating the cursor for next generation, and return the result. The returned results are aggregated.