Reflection: Endpoints
Endpoints are responsible for exposing the dynamic data that is ultimately presented in view templates. Data is pulled directly from data sources, which were discussed in the previous guide. Reflection defines backend endpoints for your application based on the data bindings it finds in your frontend.
Let's look at an example of how an endpoint is defined. Here's a view template that defines a message
data binding that presents a single value for content
:
<article binding="message">
<p binding="content">
message content goes here
</p>
</article>
Reflection defines a backend endpoint for this view template, which looks like this:
Endpoint Name HTTP Method Request Path Who Defined the Endpoint
------------- ----------- ------------ ------------------------
:root GET / pakyow/reflection
When a request is made to GET /
, the reflected endpoint will expose the correct data. You can see exactly what reflection is doing by looking at the logs. Here's what you would see for the above endpoint:
185.00μs http.9b0296e9 | GET / (for 127.0.0.1 at 2019-07-16 18:43:44 +0000)
1.39ms http.9b0296e9 | [reflection] exposing dataset for `message': #<Pakyow::Data::Proxy:70221658905980 @source=#<Sequel::SQLite::Dataset: "SELECT * FROM `messages` ORDER BY `created_at` DESC">>
9.53ms http.9b0296e9 | 200 (OK)
Endpoints are created in a controller on the backend. Controllers define routes that call backend behavior for http requests that match a specific method and path. In this case, here's what the controller would look like:
controller do
get :root, "/" do
# Reflected behavior is performed here.
end
end
If we were to write the controller ourselves, here's what it would look like with the behavior filled in:
controller do
get :root, "/" do
expose "message", data.messages.all
render "/"
end
end
Reflection keeps us from having to write code like this ourselves. Not only that, the reflection automatically updates its behavior to match changes in the view template. You can add more bindings to the template and data will be exposed for them without having to make any changes yourself. Reflection makes your backend reactive to changes in frontend view templates, rather than hardcoded to work with particular set of bindings. This reduces the amount of coordination you would otherwise need to handle yourself, shortening the feedback loop between changes in the process.
Building a resource-based view structure
Reflection makes some decisions about how to structure backend endpoints based on the structure found in your view templates. One of the conventions it relies on is REST, or Representational State Transfer.
REST defines conventions for http apis based around resources. In Reflection, a RESTful resource is defined when an endpoint will interact directly with a data source. Consider the following view template:
<article binding="message">
<p binding="content">
message content goes here
</p>
</article>
If you recall from the previous guide, Pakyow defines a messages
data source that matches the message
binding from the view template. Because the view template is defined within the resource path (messages/index.html
), Reflection defines the endpoint for this view within a messages
resource.
Resources are just controllers that implement a RESTful api. The messages/index.html
page is tied to the list
endpoint in the messages
resource. This endpoint is responsible for listing all of the messages contained by a resource. In practical terms this means that all the messages in the database will be presented by the messages/index.html
view.
REST defines several other endpoints that reflection makes use of. Here's a list, along with what view templates the endpoint is mapped to:
list
: This endpoint is responsible for presenting a list of objects for a resource. The request path is structured asGET /messages
. Reflection maps view templates likemessages/index.html
to this endpoint.show
: This endpoint is responsible for presenting a specific object within a resource. The request path is structured asGET /messages/:id
, where:id
is the unique id tied to the object in the database. Reflection maps view templates likemessages/show.html
to this endpoint.new
: This endpoint is responsible for presenting the form for a new object. The request path is structured asGET /messages/new
. Reflection maps view templates likemessages/new.html
to this endpoints.edit
: This endpoint is responsible for presenting the form for a specific object within a resource. The request path is structured asGET /messages/:id/edit
, where:id
is the unique id tied to the object in the database. Reflection maps view templates likemessages/edit.html
to this endpoint.
Nested resources
REST allows for nested resources in cases where one resource is related to another. Reflection uses this to handle associations between data sources. For example, if you wanted to present a list of replies for a specific message, you would define a view template like this:
<h1>
Replies to your message:
</h1>
<article binding="reply">
<p binding="content">
reply content goes here
</p>
</article>
Reflection renders this view with a list of replies for a specific message. The request path would be structured as GET /messages/:message_id/replies
, where :message_id
is the id of the specific object in the database.
Nested resources can also contain endpoints for show
, new
, and edit
just like top level resources.
Presenting a specific object
Reflected endpoints will always expose a full dataset for presentation, except in cases where more context is available. For example, in the case of resource show
endpoints, reflection reduces the dataset down to the specific object being shown.
Here's an example view template, defined at the show
path for the message resource:
<article binding="message">
<p binding="content">
message content goes here
</p>
</article>
Instead of exposing all the message data, the show
endpoint for this view template will present a specific object. For example, if the request path was to /messages/1
, reflection would present the message with an id
of 1
in the database. If the object doesn't exist, the 404 (Not Found) page would be presented instead.
Reflection always chooses to present the specific object whenever additional context is available for the binding type. This is the case for edit
endpoints, as well as endpoints for nested resources.
Presenting nested data
Reflection will automatically include and present nested data when it can. For example, say you have a view template that presents messages along with replies for each message. Here's what the view template might look like:
<article binding="message">
<p binding="content">
message content goes here
</p>
<ul>
<li binding="reply">
<p binding="content">
reply content goes here
</p>
</li>
</ul>
</article>
Reflection automatically creates a replies
data source that's associated with the messages
data source. The related endpoint will expose the messages that exist, along with any replies for each message. All of the data is presented together, resulting in a rendered view that looks something like this:
<article>
<p>
message 1
</p>
<ul>
<li binding="reply">
<p binding="content">
first reply to message 1
</p>
</li>
</ul>
</article>
<article>
<p>
message 2
</p>
<ul>
<li binding="reply">
<p binding="content">
first reply to message 2
</p>
</li>
<li binding="reply">
<p binding="content">
second reply to message 2
</p>
</li>
</ul>
</article>
<article>
<p>
message 3
</p>
<ul>
</ul>
</article>
Notice that Message 3 doesn't have any replies. This is because no replies had been created for the message in the database. You can handle this use-case more elegantly using an empty
version for the reply
binding:
<article binding="message">
<p binding="content">
message content goes here
</p>
<ul>
<li binding="reply">
<p binding="content">
reply content goes here
</p>
</li>
<li binding="reply" version="empty">
no replies to this message yet
</li>
</ul>
</article>
Now the rendered view would look something like this:
<article>
<p>
message 1
</p>
<ul>
<li binding="reply">
<p binding="content">
first reply to message 1
</p>
</li>
</ul>
</article>
<article>
<p>
message 2
</p>
<ul>
<li binding="reply">
<p binding="content">
first reply to message 2
</p>
</li>
<li binding="reply">
<p binding="content">
second reply to message 2
</p>
</li>
</ul>
</article>
<article>
<p>
message 3
</p>
<ul>
<li>
no replies to this message yet
</li>
</ul>
</article>
Presenting to multiple bindings of a single type
Sometimes you might want to present the same type of data twice on the same page. For example, maybe you want to present two lists of messages: one for the most recent posts and another ordered alphabetically. Pakyow gives you a way to accomplish this with a feature called binding channels.
Here's what the view template might look like for a use-case like this:
<h1>
Most recent messages:
</h1>
<article binding="message:recent">
<p binding="content">
message content goes here
</p>
</article>
<h1>
Messages in alphabetical order:
</h1>
<article binding="message:alphabetical">
<p binding="content">
message content goes here
</p>
</article>
Each message
binding is defined with a channel, respectively named recent
and alphabetical
. In the next section we'll learn how to present different datasets for each of these bindings.
Presenting a custom dataset
Reflection lets you define custom datasets for bindings. This is useful when you want to override the default dataset, which is either all the data for a binding or, in the case of show endpoints, a specific object for the binding.
Building on the example from the previous section, we can specify the dataset to use for each instance of the binding. We'll present a dataset with recent messages in the first binding, and an alphabetically ordered dataset in the second binding.
Here's what the view template looks like with defined datasets:
<h1>
Most recent messages:
</h1>
<article binding="message:recent" dataset="query: all; order: created_at(desc)">
<p binding="content">
message content goes here
</p>
</article>
<h1>
Messages in alphabetical order:
</h1>
<article binding="message:alphabetical" dataset="query: all; order: content(asc)">
<p binding="content">
message content goes here
</p>
</article>
Each dataset defines two aspects: 1) the query to perform and 2) how to order results. There's also third option for limiting the dataset. We'll cover each of these more below.
Dataset queries
Queries are defined on the data source that matches the binding. All data sources define an all
query by default that returns all of the results from the database. You can define additional queries by defining it on the data source:
source :messages do
def important
where(build("content like ?", "sos"))
end
end
This query returns results that contain "sos" somewhere in their content. The new important
query can be used in a from template like this:
<h1>
Important messages:
</h1>
<article binding="message" dataset="query: important">
<p binding="content">
message content goes here
</p>
</article>
- Read more about queries for backend data sources →
Dataset ordering
Datasets can be ordered by any of their attributes, in ascending or descending order. Every aspect of ordering can be expressed in the view template. For example, here's how you would order a dataset by created_at
date availbale on every data source:
<article binding="message" dataset="order: created_at">
<p binding="content">
message content goes here
</p>
</article>
By default, datasets are ordered in ascending order. If you want to put the most recent messages at the top, you would specify descending order in the view template:
<article binding="message" dataset="order: created_at(desc)">
<p binding="content">
message content goes here
</p>
</article>
Datasets can also be ordered by multiple attributes:
<article binding="message" dataset="order: created_at(desc), content">
<p binding="content">
message content goes here
</p>
</article>
Here the dataset will first be ordered by creation date (most recent first), then alphabetically based on the content of each message.
Dataset limiting
Datasets can be limited to a specific number of results using the limit
keyword:
<article binding="message" dataset="limit: 10">
<p binding="content">
message content goes here
</p>
</article>
Here, only 10 messages will be presented in this view template.
Customizing reflected endpoints
Reflection is designed to integrate with any existing backend endpoints that might exist in your project. It extends your application with endpoints only when the doesn't already exist. This gives you a way to replace a reflected endpoint entirely, or extend it to fit your specific needs.
Replacing an endpoint
You can replace a reflected endpoint by defining it yourself. Reflection does not require you to setup your backend endpoints in any particular structure, instead it just looks for a route that matches the request path for the endpoint.
Let's look at an example. In this case, we'll define an endpoint for presenting messages at the root path in our application. Endpoints are defined within controllers, like this:
controller "/" do
default do
# Custom behavior can be performed here.
end
end
The "/"
argument defines the request path that the controller is mounted at. Within the controller is a default
route that matches requests to GET /
. Defining the controller with a default route is all that's required to replace the reflected behavior. Reflection will see that an endpoint already exists and will step aside and let the custom code handle requests.
Extending an endpoint with operations
Endpoints can be extended rather than completely replaced. There are a couple of ways to do this depending on what exactly you need to do. The first approach is to extend the reflect
operation.
Operations are a backend feature in Pakyow that let you define a sequence of steps to perform in order. Most of the business logic for an application will be defined in operations, and reflection is no exception. There's only a single step in the reflect
operation that pertains to endpoints:
expose
: Exposes datasets for every binding in the related view template.
Operations can be extended at runtime, injecting custom behavior where you need it. Each step is defined as an "action" on the operation. Here's how you can extend the reflect
operation to replace the expose
step:
controller "/" do
default do
operations.reflect do
action :expose do
# Custom behavior goes here.
end
end
end
end
Custom behavior you define in the new expose
action will be used instead of the default behavior defined by reflection.
You can also insert new steps into the operation. Here's how you can add behavior before the expose
step:
controller "/" do
default do
operations.reflect do
action :custom, before: :expose do
# Custom behavior goes here.
end
end
end
end
Now the entire reflect
operation will be called, but your custom behavior will be called before the built-in expose
behavior.
Extending an action with helpers
Reflection makes several helper methods available for you to use. You can use these helpers as primitives to build your own custom behavior with. Here's a list of available endpoint helpers:
with_reflected_endpoint
Lets you safely perform behavior if a reflected endpoint is available.
with_reflected_endpoint do |endpoint|
# only called if a reflected endpoint is available
end
reflective_expose
Exposes a dataset for every binding in the view template.