Part 6 - Networking: http
*************************
Let's go for the next step.
Copy the ``top5`` folder to ``top6`` and enter it. For example, with::
cp -r top5 top6
cd top6
.. note:: Under *Windows* and unless you have a proper shell installed
(*Cygwin*, *MSYS*, *GitBash*, ...) you are probably better off
using the *Windows Explorer* to make a copy of the directory)
The application is growing, but so far the *Pyroes* have been sourced directly
from a list of objects. A real life web application would fetch the information
from the network. And that's why we are also going to do it ... virtually.
*AnPyLar* includes an *Http* client which can fetch data from the network using
*ajax*, but can also deliver data from local sources. To avoid having to set up
a server with an API, we'll start by using the local sourcing facility.
The additions to this:
- Adding / Editing / Deleting Pyroes (Editing was the only operation
previously supported)
- Searching for *Pyroes*
- Doing it all with the *Http* client
After we are done, the final layout will look like this
.. tabs::
.. code-tab:: bash Layout
├── app
│ ├── dashboard
│ │ ├── __init__.py
│ │ ├── dashboard_component.css
│ │ ├── dashboard_component.html
│ │ └── dashboard_component.py
│ ├── pyro_detail
│ │ ├── __init__.py
│ │ ├── pyro_detail_component.css
│ │ ├── pyro_detail_component.html
│ │ └── pyro_detail_component.py
│ ├── pyro_search
│ │ ├── __init__.py
│ │ ├── pyro_search_component.css
│ │ ├── pyro_search_component.html
│ │ └── pyro_search_component.py
│ ├── pyroes
│ │ ├── __init__.py
│ │ ├── pyroes_component.css
│ │ ├── pyroes_component.html
│ │ └── pyroes_component.py
│ ├── __init__.py
│ ├── app_component.css
│ ├── app_component.html
│ ├── app_component.py
│ ├── app_module.py
│ ├── app_routing.py
│ ├── mock_pyroes.py
│ ├── pyro.py
│ ├── pyro_search_service.py
│ └── pyro_service.py
├── anpylar.js
├── index.html
├── package.json
└── styles.css
Notice that we will be adding:
- A ``pyro_search`` directory for the ``PyroSearchComponent``
- A ``pyro_search_service.py`` for the ``PyroSearchService``
The top-level changes
---------------------
Let's first see the modification made at the top level to accomodate change
.. tabs::
.. code-tab:: python mock_pyroes.py
Pyroes = [
{'pyd': 11, 'name': 'Pyro Nakamura'},
{'pyd': 12, 'name': 'Mopynder Shuresh'},
{'pyd': 13, 'name': 'Pyter Pytrelli'},
{'pyd': 14, 'name': 'Angela Pytrelli'},
{'pyd': 15, 'name': 'Claire Pynnet'},
{'pyd': 16, 'name': 'Noah Pynnet'},
{'pyd': 17, 'name': 'Pysaac Mendez'},
{'pyd': 18, 'name': 'Pyki Sanders'},
{'pyd': 19, 'name': 'The Pytian'},
{'pyd': 20, 'name': 'Pylar'},
]
.. code-tab:: python app_module.py
from anpylar import Module, Http
from .app_component import AppComponent
from .app_routing import AppRouting
from .pyro_service import PyroService
from .pyro_search import PyroSearchComponent
from .pyro_search_service import PyroSearchService
if True:
from .mock_pyroes import Pyroes
Http.serve(Pyroes, index='pyd', url='api/pyroes/')
class AppModule(Module):
components = AppComponent
bindings = {}
services = {
'pyro_service': PyroService,
'pyro_search': PyroSearchService,
}
routes = AppRouting
def __init__(self):
pass
The ``Pyroes`` in *mock_pyroes.py* are no longer *Pyro* instances. The
structure is an iterable of *dict* entries. And this is so to reflect data that would
flow from the network (because the translation to/from *Json* is
straightforward) and somehow the structure of database.
In *app_module.py* we do:
- Import ``PyroSearchComponent``. This is to make it usable in our
``DashboardComponent``, because the *pyro_search* is part of no route (we
could have of course imported in the package defining our
``DashboardComponent``. Do it so if you prefer.
- Declare the ``PyroSearchService`` in the ``services`` directive, so that it
will be started for us
- And fake the network service with
.. code-block:: python
if True:
from .mock_pyroes import Pyroes
Http.serve(Pyroes, index='pyd', url='api/pyroes/')
The ``Http`` client in *AnPyLar* can accept an iterable of *dict* entries
(you tell it what the key is for sorting purposes) and hijack requests that
are directed to specicic routes. In our case: ``url='api/pyroes'``. There
will be a basic *CRUD* interface mapped to the *POST / GET / PUT / DELETE*
http methods.
With that in hand we can have a look at the changes/additions to the services.
The Services
------------
.. tabs::
.. code-tab:: python pyro_service.py
from anpylar import Observable, http
from .pyro import Pyro
import json
class PyroService:
def __init__(self):
self.http = http.Http(
url='api/pyroes/',
headers={'Content-Type': 'application/json'},
)
def handle_error(self, e, retval=False):
print(e)
return retval
def get_pyroes(self):
return self.http.get() \
.map(lambda x: [Pyro(**p), for p in json.loads(x)]) \
.catch_exception(lambda e: self.handle_error(e, []))
def get_pyro(self, pyd):
return self.http.get(url='{}'.format(pyd)) \
.map(lambda x: Pyro(**json.loads(x))) \
.catch_exception(lambda e: self.handle_error(e, Pyro()))
def update_pyro(self, pyro):
return self.http.put(url='{}'.format(pyro.pyd),
data=json.dumps({'name': pyro.name})) \
.catch_exception(lambda e: self.handle_error(e))
def delete_pyro(self, pyd):
return self.http.delete(url='{}'.format(pyd)) \
.catch_exception(lambda e: self.handle_error(e))
def add_pyro(self, name):
return self.http.post(data=json.dumps({'name': name})) \
.map(lambda x: Pyro(**json.loads(x))) \
.catch_exception(lambda e: self.handle_error(e))
.. code-tab:: python pyro_search_service.py
from anpylar import http, Observable
from .pyro import Pyro
import json
class PyroSearchService:
def __init__(self):
self.http = http.Http(
url='api/pyroes/',
headers={'Content-Type': 'application/json'},
)
def search(self, term):
return self.http.get(data={'name': term}) \
.map(lambda x: [Pyro(**p), for p in json.loads(x)])
Our ``PyroService`` has grown quite a bit, but it is still generic. It uses the
``Http`` client to issue the aforementioned *POST / GET / PUT / DELETE* calls
in order to implement the CRUD interface.
The new ``PyroSearchService`` is also modelled as an *Observable* and simply
retrieves the *Pyroes* which match the search criterion (which is simply to
contain the sought text string)
It is here that the *Json* stream from the network gets translated to *Pyro*
instances. Recall that we have redefined the *Pyroes* in ``mock_pyroes.py`` to
be an iterable of *dict* entries, because it simulates network information and
a database structure.
Changes in the Dashboard
------------------------
.. tabs::
.. code-tab:: html dashboard_component.html
Top Pyroes
There is a single change for the *dashboard* in ``dashboard_component.html``
which is the addition of the ```` tag. Just below the *Pyroes*,
the dashboard will display our search component. Recall that we have imported
the component in ``app_module.py`` (and it will for sure have to define:
``selector = 'pyro-search'``
Changes in the Pyroes
---------------------
.. tabs::
.. code-tab:: html pyroes_component.html
My Pyroes
.. code-tab:: python pyroes_component.py
from anpylar import Component, html
class PyroesComponent(Component):
bindings = {
'pyro_name': '',
}
def loading(self):
self.pyro_service.get_pyroes().subscribe(self.pyroes_)
def unloading(self):
self.pyroes_ = []
def render(self, node):
# render under ul in render_pyroes when observable self.pyroes_ fires
with node.select('ul') as ul: # find node where to display the list
ul._render(self.render_pyroes, self.pyroes_)
def render_pyroes(self, pyroes):
for pyro in pyroes:
with html.li() as li: # per-pyro list item
# per-pyro anchor routing path with parameter pyd
with html.a(routerlink=('/detail', {'pyd': pyro.pyd})):
html.span(pyro.pyd, Class='badge') # show pyd as badge
html.txt(' {name}')._fmt(name=pyro.name_) # obs name_
with html.button('x', Class='delete') as b:
# def param avoids closure using last pyro.pyd
def pyro_delete(evt, pyd=pyro.pyd):
evt.stopPropagation() # avoid evt clicking on "a"
self.pyro_delete(pyd)
b._bind.click(pyro_delete) # use "bind" to get event
def pyro_add(self):
self.pyro_service.add_pyro(self.pyro_name).subscribe(
lambda pyro: self.pyroes_(self.pyroes + [pyro])
)
self.pyro_name_ = ''
def pyro_delete(self, pyd):
self.pyro_service.delete_pyro(pyd) \
.subscribe(
lambda x: self.pyroes_([x for x in self.pyroes if x.pyd != pyd])
)
The ``PyroesComponent`` has acquired in the html code
(``pyroes_component.html``) an *input* field with an ``Add`` button, which will
obviously serve to add new *Pyro* instances.
The Python code generates an extra ``x`` appended to the name of the *Pyro*,
which serves as a button to *delete* them (the proper styling helps)
Both the ``Add`` and ``Delete`` actions are served by the methods ``pyro_add``
and ``pyro_delete`` which piggyback on ``PyroService`` for the actions.
Changes in the PyroDetail
-------------------------
.. tabs::
.. code-tab:: html pyro_detail_component.py
{name} Details
pyd: {}
.. code-tab:: python pyro_detail_component.py
from anpylar import Component, html
from app.pyro import Pyro
class PyroDetailComponent(Component):
bindings = {
'pyro': Pyro(),
}
def loading(self):
self.pyro_service \
.get_pyro(self.params.get('pyd', 0)) \
.subscribe(self.pyro_) # fetch async and fire self.pyro_ when done
def unloading(self):
self.pyro = Pyro() # clear the editor on unloading: set null Pyro
def render(self, node):
pass # the entire work is done in the html rendering
def save(self):
self.pyro_service.update_pyro(self.pyro) \
.subscribe(lambda x: self.router.back())
The component changes its behavior in a subtle manner:
- Editions to the name of a *Pyro* have to be *saved*
Before it was just a matter of editing and the changes were shared across
components. But now, the component will be talking to the network service
and updating the name remotely.
After that it is the responsibility of other components to fetch the changes
.. note:: This is obviously not very efficient, because the application could
cache the changes internally and avoid hitting the network for
everything each and every time. But this is just a sample to show how
various components coordinate over the network service.
To implement the behavior, there are new elements, namely:
- A ``Save`` button defined in the html code which binds to the method
``save`` (no surprise here when it comes to naming conventions)
- A ``save`` method which *updates* the *Pyro* with the method
``update_pyro`` of the ``PyroService``
The new PyroSearch
------------------
We have already seen above that the *dashboard* has gained a ````
tag which will be implemented by a new component. And we have already created
the service ``PyroSearchService`` which will be used by it.
Let's create the skeleton of the component with the cli. For example::
cd top6/app
anpylar-component Dashboard
And we add the code, html and css content.
.. tabs::
.. code-tab:: html pyro_search_component.html
Pyro Search
.. code-tab:: python pyro_search_component.py
from anpylar import Component, html
class PyroSearchComponent(Component):
selector = 'pyro-search'
bindings = {
'pyroes': [],
'searchterm': '',
}
services = {}
def __init__(self):
# connect searchterm to the found pyroes to be displayed
self.searchterm_ \
.debounce(300) \
.distinct_until_changed() \
.switch_map(lambda x: self.pyro_search.search(x) if x else []) \
.catch_exception(lambda e: print('search error:', e) or []) \
.subscribe(self.pyroes_)
def unloading(self):
self.pyroes = [] # clear result
self.searchterm = '' # clear search box
def render(self, node):
def sought_pyroes(pyroes):
for p in pyroes:
with html.li() as li: # per-pyro list item
# per-pyro anchor routing path with parameter pyd
html.a(p.name, routerlink=('/detail', {'pyd': p.pyd}))
with node.select('ul') as ul:
ul._render(sought_pyroes, self.pyroes_)
.. code-tab:: css pyro_search_component.css
.search-result li {
border-bottom: 1px solid gray;
border-left: 1px solid gray;
border-right: 1px solid gray;
width:195px;
height: 16px;
padding: 5px;
background-color: white;
cursor: pointer;
list-style-type: none;
}
.search-result li:hover {
background-color: #607D8B;
}
.search-result li a {
color: #888;
display: block;
text-decoration: none;
}
.search-result li a:hover {
color: white;
}
.search-result li a:active {
color: white;
}
#search-box {
width: 200px;
height: 20px;
}
ul.search-result {
margin-top: 0;
padding-left: 0;
}
The Html code
- Defines an *input* for which we add a bidirectional binding to an
observable (which will be defined in the python code) with:
``*_fmtvalue="searchterm_"``
- Define a place holder with ``
``. It will be
inside in the form of dynamically created ``
`` tags that the search
results will be displayed.
The Python Code
- Defines bindings (Observables) for the functionality (``pyroes`` and
``searchterm``) which will for example be cleared during the ``unloading``
phase. This is meant to clear the list of search results and the input
field for the search.
- Defines a dynamic rendering with ``ul._render(sought_pyroes,
self.pyroes_)``
Notice that unlike in previous similar bindings, we have defined ``def
sought_pyroes`` inline. Previously we used methods of the component. Just a
different way of doing it.
- Applies a chain of operations to the ``searchterm_`` observable. Let's see
it
.. code-block:: python
self.searchterm_ \
.debounce(300) \
.distinct_until_changed() \
.switch_map(lambda x: self.pyro_search.search(x) if x else []) \
.catch_exception(lambda e: print('search error:', e) or []) \
.subscribe(self.pyroes_)
- ``debounce(300)``: if the *searchterm* changes within 300ms, discard the
previous term. This effectively buffers the sought term for a maximum of
300ms to see if there are any changes. When *typing*, this is quite
common.
- ``distinct_until_changed()``: if the sought term is resent but it is the
same as the last one, simply discard it
- ``switch_map(lambda x: self.pyro_search.search(x) if x else [])``: if the
result of the previous operations is an empty search term, return
directly a list of empty results (which will converted internally to an
Observable). Else: ask the network service for results. The network
service returns an Observable which will be *observed* for the results
- ``catch_exception(lambda e: print('search error:', e) or [])``: If any
error happens log it to the console and return a list of empty
results. This is a trick, because ``print`` returns ``None`` and the
logical ``or`` condition will alwys return the 2nd part of the expression
- ``subscribe(self.pyroes_)``: And pass whatever results the chain produces
to the subscriptor which is our binding (observable)
``self.pyroes_``. Because we have bound the rendering under ``
`` to
any changes in this binding, when the results arrive, ``
`` will be
re-rendered
Let's execute
-------------
After some groundbreaking changes, let's see how things work
::
anpylar-serve top6
And go the browser
http://127.0.0.1:2222
The start screen with the dashboard
.. image:: top6-dashboard.png
And because this is inviting, let's type something exciting like ``py``. If you
type it fast, there will be no results displayed until ``300ms`` after you end
typing it (this is a static document ... difficult to show it).
.. image:: top6-dashboard-py.png
In this case all *Pyroes* will be shown because they are all **Py** ro. Add a
``t`` to the string to form a ``pyt``
.. image:: top6-dashboard-pyt.png
The list has been reduced to 3 items. Play with the search functionality as
much as you like. When you are done, let's go to our usual *Pyroes* list
.. image:: top6-pyroes.png
And let's give our new ``Add`` and ``Delete`` functionalities a try. We'll be
deleting the 2nd Pyro (our friend *Mopynder*) to see how this is reflected in
the dashboard and we'll be adding a new **super-pyro**: *Molpy Sanders*, who
can locate any other *Pyro*.
.. image:: top6-pyroes-before-add.png
And after pressing the ``Add`` button
.. image:: top6-pyroes-after-add.png
Going back to the *Dashboard*
.. image:: top6-dashboard-after-delete.png
Our old friend *Mopynder Shuresh* is no longer part of the Top Pyroes. In fact,
if we search for him, we'll realize he's no longer part of the *Pyroes* at all
(remember we have deleted him)
.. image:: top6-dashboard-no-mopynder.png
Last but not least, let's see how our editor looks like.
.. image:: top6-editor.png
Up to the reader: edit and *Go back*. The changes won't be there. Edit and
*Save* and the changes will be there (in the Dashboard -if part of the Top
Pyroes-, in the search results -if found-, and in the list of *Pyroes*)
Conclusion
----------
After all this ... who knows what the *Tour* will bring for our *Pyroes*