Guillotina: La nostra primera aplicació (II)

Roger Boixader Güell

17/1/2023

En aquest segon article sobre guillotina construirem una aplicació de xat, seguirem el tutorial que hi ha en la seva documentació.

1.0 - Prerequisits i instal·lació del paquet de guillotina

Tal com vam explicar en el primer article sobre guillotina, primer de tot necessitem tenir:

  • Python >= 3.7
  • Docker
  • Postman ( o qualsevol altre client per fer peticions al nostre servidor )

Per començar crearem l'entorn virtual, i instal·larem guillotina i cookiecutter.

mkdir guillotina_app_folder
cd guillotina_app_folder
python3 -m venv genv
cd genv
source ./bin/activate
cd ..
pip install guillotina
pip install cookiecutter

Cal recordar que si volem tenir una base de dades PostgreSQL, ho podem fer creant un contenidor de docker.

docker run \
  -e POSTGRES_DB=guillotina -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres \
  -p 127.0.0.1:5432:5432 \
  postgres:15.1

En el primer article sobre guillotina creàvem un arxiu de configuració per poder modificar quina base de dades volem utilitzar. En aquest cas en comptes de crear un sol arxiu de configuració crearem un paquet, usant les plantilles que ja ens proporciona guillotina.

guillotina create --template=application

Quan ens demani el package_name li posarem guillotina_chat.

En el mateix directori ens ha creat dos arxius g.db i g.db.blobs, els podem eliminar, ja que farem servir postreSQL com a base de dades.

Instal·larem el paquet que acabem de crear.

cd guillotina_chat
pip install -e .

2.0 - Configuració

Modifiquem l'arxiu de configuració config.yaml per utilitzar la PostgreSQL:

databases:
  db:
    storage: postgresql
    dsn: postgresql://postgres:postgres@localhost:5432/guillotina
    read_only: false

En el mòdul de configuració de Guillotina i l'objecte app_settings podem ampliar qualsevol configuració.

La definició dels content types, behaviors, serveis, etc necessiten configurar-se en el mòdul de configuració. Guillotina llegeix tota la configuració per cada aplicació carregada i la carrega.

L'arxiu app_settings

Guillotina proporciona l'objecte global app_settings

from guillotina import app_settings

Aquest objecte conte tota la configuració del nostre arxiu config.yaml i tota la configuració addicional. La prioritat de configuració a guillotina funciona:

  • La configuració per defecte de guillotina
  • Cada aplicació, en l'ordre que estigui definida, pot sobreescriure la configuració per defecte de guillotina
  • Finalment l'arxiu config.yaml té la prioritat final sobre tota la configuració.

L'arxiu app_settings té una configuració extra amb la clau __file__ que conté la ruta de l'arxiu de configuració, permetent que les rutes relatives es puguin utilitzar en la configuració de les aplicacions.

3.0 - Creant content types

Un cop ja tenim la guillotina configurada, podem crear el nostre primer content type. Per poder intercanviar missatges en el xat necessitem l'entitat de conversa.

Crearem l'arxiu content.py en la nostra aplicació. (a la mateixa carpeta on hi ha el fitxer de __init__.py)

from guillotina import configure, content, schema
from guillotina.directives import index_field
from guillotina.interfaces import IFolder, IItem


class IConversation(IFolder):
    index_field("users", type="keyword")
    users = schema.List(
        value_type=schema.TextLine(),
        default=list()
    )


@configure.contenttype(
    type_name="Conversation",
    schema=IConversation,
    behaviors=["guillotina.behaviors.dublincore.IDublinCore"],
    allowed_types=['Message'])
class Conversation(content.Folder):
    pass


class IMessage(IItem):
    index_field("text", type="text")
    text = schema.Text(required=True)


@configure.contenttype(
    type_name="Message",
    schema=IMessage,
    behaviors=[
        "guillotina.behaviors.dublincore.IDublinCore",
        "guillotina.behaviors.attachment.IAttachment"
    ],
    globally_addable=False,
)
class Message(content.Item):
    pass

NOTA: Quan modifiquem guillotina, els canvis no s'actualitzen en calent, és a dir, hem de parar el servidor i tornar-lo a arrancar per veure els canvis aplicats.

Analitzem una mica el que acabem de fer, hem creat dos nous content types el Conversation i el Message.

Per crear els content types primer hem de crear una interficie que serà on definim tots els atributs que tindrà aquell tipus. La conversa volem poder afegir-hi elements a dintre, així que la crearem heretant de Folder en canvi, el missatge volem que sigui un objecte que no s'hi puguin afegir elements a dins, així que el crearem heretant de Item.

Un cop hem creat les dues interfícies, crearem els content types, quan els creem li podem definir quins behaviors volem que tinguin tots els objectes que crearem d'aquell tipus.

La funció index_field exposa aquest camp perquè pugui ser cercat amb el punt d'entrada @search.

En la configuració del Message estem marcant amb el globally_addable=False amb el qual limitem a què només podem afegir objectes del tipus Message a dins d'objectes del tipus Conversation.

Perquè guillotina detecti aquesta nova configuració, hem de dir-li a guillotina que escanegi el nostre arxiu en l'arxiu __init__.py, d'aquesta manera detectarà aquests nous content types que acabem de crear.

from guillotina import configure
configure.scan('guillotina_chat.content')

Per comprovar que hem creat correctament els nostres content types crearem un objecte del tipus Conversation i a dins un del tipus Message.

Abans per això haurem de crear el nostre contenidor de l'aplicació.

guillotina serve -c config.yaml
curl --location --request POST 'http://localhost:8080/db/' \
--header 'Authorization: Basic cm9vdDpyb290' \
--header 'Content-Type: application/json' \
--data-raw '{
    "id":"container",
    "@type":"Container"
}'

Creem la conversa:

curl --location --request POST 'http://localhost:8080/db/container/' \
--header 'Authorization: Basic cm9vdDpyb290' \
--header 'Content-Type: application/json' \
--data-raw '{
    "id":"primera_conversa",
    "title":"Primera conversa",
    "@type":"Conversation"
}'

Creem el missatge:

curl --location --request POST 'http://localhost:8080/db/container/primera_conversa' \
--header 'Authorization: Basic cm9vdDpyb290' \
--header 'Content-Type: application/json' \
--data-raw '{
    "id":"primer_missatge",
    "title":"Primer missatge",
    "text": "Aquest és el text del primer missatge",
    "@type":"Message"
}'

Si ara fem una petició GET a l'objecte conversa, podrem veure tota la seva informació i en el camp items com hi ha també el missatge que acabem de crear. De la mateixa manera també podríem fer un GET al missatge per poder veure la seva informació.

curl --location --request GET 'http://localhost:8080/db/container/primera_conversa' \
--header 'Authorization: Basic cm9vdDpyb290'

4.0 - Instal·lant addons

Guillotina diferencia les aplicacions dels addons ( complement/extensió ) Una aplicació és un paquet de Python que tu instal·les en el teu entorn i l'afegeixes a la llista d'aplicacions a l'arxiu de configuració. En canvi, els addons, són per quan volem crear lògica d'instal·lació en el container.

Per exemple en el nostre cas volem crear una carpeta que ja tingui uns permisos predefinits.

Per definir un addon utilitzarem el decorador @configure.addon en l'arxiu install.py de la nostra aplicació.

# install.py
from guillotina import configure
from guillotina.addons import Addon
from guillotina.content import create_content_in_container
from guillotina.interfaces import IRolePermissionManager

@configure.addon(
    name="guillotina_chat",
    title="Guillotina server application python project")
class ManageAddon(Addon):

    @classmethod
    async def install(cls, container, request):
        roleperm = IRolePermissionManager(container)
        roleperm.grant_permission_to_role_no_inherit(
            'guillotina.AccessContent', 'guillotina.Member')

        if not await container.async_contains('conversations'):
            conversations = await create_content_in_container(
                container, 'Folder', 'conversations',
                id='conversations', creators=('root',),
                contributors=('root',))
            roleperm = IRolePermissionManager(conversations)
            roleperm.grant_permission_to_role(
                'guillotina.AddContent', 'guillotina.Member')
            roleperm.grant_permission_to_role(
                'guillotina.AccessContent', 'guillotina.Member')

    @classmethod
    async def uninstall(cls, container, request):
        registry = task_vars.registry.get()  # noqa
        # uninstall logic here...

NOTA: Quan modifiquem guillotina, els canvis no s'actualitzen en calent, és a dir, hem de parar el servidor i tornar-lo a arrancar per veure els canvis aplicats.

Com podem veure, quan instal·lem aquest addon, crearem una carpeta amb la clau conversations i li donarem permís d'afegir contingut i accés als usuaris del rol guillotina.Member

Per instal·lar l'addon farem una petició POST al punt d'entrada @addon.

curl -i -X POST http://localhost:8080/db/container/@addons -H 'Accept: application/json' -H 'Content-Type: application/json' --data-raw '{"id": "guillotina_chat"}' --user root:root

Ara si fem una petició GET al nostre contenidor, podrem veure dues carpetes, primer la que hem creat al pas anterior i la que acabem de crear en instal·lar l'addon.

5.0 - Permissions/Role

Els permisos els definim en el codi de la nostra aplicació. Per la nostra aplicació crearem rols que els usuaris tindran en una conversa.

En l'arxiu __init__.py afegeix:

# __init__.py 
configure.role("guillotina_chat.ConversationParticipant",
               "Conversation Participant",
               "Users that are part of a conversation", False)
configure.grant(
    permission="guillotina.ViewContent",
    role="guillotina_chat.ConversationParticipant")
configure.grant(
    permission="guillotina.AccessContent",
    role="guillotina_chat.ConversationParticipant")
configure.grant(
    permission="guillotina.AddContent",
    role="guillotina_chat.ConversationParticipant")

Acabem de crear un rol pels usuaris que participaran en una conversa, i estem donant els permisos d'afegir contingut, accedir-hi i veure'l.

6.0 - Subscrivint-nos a esdeveniments

Els esdeveniments a Guillotina estan molt influenciats pels esdeveniments de Zope. Per la nostra aplicació, volem assegurar-nos que tots els usuaris que formen part de la conversa tinguin el permís per afegir nous missatges i veure'n d'altres.

Per fer-ho crearem un subscriptor quan s'afegeix i es modifica l'objecte, on modificarem els permisos. Guillotina ens permet fer accions a varis esdeveniments com pot ser abans de crear un objecte, un cop s'ha creat, modificat, eliminat, etc. Pots veure més informació sobre els esdeveniments en la documentació

Crea un arxiu subscribers.py a la teva aplicació:

# subscribers.py
from guillotina import configure
from guillotina.interfaces import (IObjectAddedEvent, IObjectModifiedEvent,
                                   IPrincipalRoleManager)
from guillotina.utils import get_authenticated_user_id

from guillotina_chat.content import IConversation


@configure.subscriber(for_=(IConversation, IObjectAddedEvent))
@configure.subscriber(for_=(IConversation, IObjectModifiedEvent))
async def container_changed(conversation, event):
    user_id = get_authenticated_user_id()
    if user_id not in conversation.users:
        conversation.users.append(user_id)

    manager = IPrincipalRoleManager(conversation)
    for user in conversation.users:
        manager.assign_role_to_principal(
            'guillotina_chat.ConversationParticipant', user)

Perquè guillotina detecti la nostra configuració, hem d'afegir l'arxiu en els includeme de l'arxiu inicial __init__.py

from guillotina import configure
configure.scan('guillotina_chat.subscribers')

NOTA: Quan modifiquem guillotina, els canvis no s'actualitzen en calent, és a dir, hem de parar el servidor i tornar-lo a arrancar per veure els canvis aplicats.

Per comprovar-ho podem crear una nova conversa i un nou missatge, i amb el punt d'entrada @sharing fent una petició GET podrem comprovar quins permisos s'han assignat a l'objecte.

curl --location --request POST 'http://localhost:8080/db/container/' \
--header 'Authorization: Basic cm9vdDpyb290' \
--header 'Content-Type: application/json' \
--data-raw '{
    "id":"segona_conversa",
    "title": "Segona conversa",
    "@type":"Conversation"
}'
curl --location --request POST 'http://localhost:8080/db/container/segona_conversa/' \
--header 'Authorization: Basic cm9vdDpyb290' \
--header 'Content-Type: application/json' \
--data-raw '{
    "id":"primer_missatge_segona_conversa",
    "title": "Primer missatge segona conversa",
    "text": "Aquest es el primer missatge",
    "@type":"Message"
}'
curl --location --request GET 'http://localhost:8080/db/container/segona_conversa/@sharing' \
--header 'Authorization: Basic cm9vdDpyb290' \
--data-raw ''

Resposta del @sharing

{
    "local": {
        "roleperm": {},
        "prinperm": {},
        "prinrole": {
            "root": {
                "guillotina.Owner": "Allow",
                "guillotina_chat.ConversationParticipant": "Allow"
            }
        }
    },
    "inherit": [
        {
            "@id": "http://localhost:8080/db/container",
            "roleperm": {
                "guillotina.Member": {
                    "guillotina.AccessContent": "AllowSingle"
                }
            },
            "prinperm": {},
            "prinrole": {
                "root": {
                    "guillotina.ContainerAdmin": "Allow",
                    "guillotina.Owner": "Allow"
                }
            }
        }
    ]
}

Aquí podem veure com en el nou objecte hi tenim el permís que hem afegit en el subscriptor de quan creem un objecte.

7.0 - Usuaris

Guillotina ens proporciona un paquet per gestionar usuaris i grups.

Instal·lació

Hem d'afegir l'aplicació guillotina.contrib.dbusers a la llista d'aplicacions a la configuració en el config.yaml. Després d'afegir l'aplicació hem de reiniciar el servidor, i seguidament afegir l'addon de dbusers mitjançant el punt d'entrada @addons.

curl -i -X POST http://localhost:8080/db/container/@addons -H 'Accept: application/json' -H 'Content-Type: application/json' --data-raw '{"id": "dbusers"}' --user root:root

Afegir usuaris

Per crear usuaris, només hem d'afegir un objecte del tipus User

curl -i -X POST http://localhost:8080/db/container/users -H 'Accept: application/json' -H 'Content-Type: application/json' --data-raw '{"@type": "User", "email": "bob@domain.io", "password": "secret", "user_roles": ["guillotina.Member"], "username": "bob"}' --user root:root

Un cop hem creat l'usuari podem iniciar sessió amb el punt d'entrada @login

curl -i -X POST http://localhost:8080/db/container/@login -H 'Accept: application/json' -H 'Content-Type: application/json' --data-raw '{"password": "secret", "username": "bob"}' --user root:root

En la resposta de l'inici de sessió, obtenim el token d'accés per poder realitzar les següents crides com a usuari ja ha iniciat la sessio. El token és del tipus JSON web token.

HTTP/1.1 200 OK
Content-Type: application/json

{
    "exp": 1532253747,
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1MzIyNTM3NDcsImlkIjoiQm9iIn0.1-JbNe1xNoHJgPEmJ05oULi4I9OMGBsviWFHnFPvm-I"
}

Llavors, en les peticions futures afegirem aquest token en les capçaleres com a Bearer. Per exemple per crear una conversa farem:

curl -i -X POST http://localhost:8080/db/container/conversations/ -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1MzIyNTM3NDcsImlkIjoiQm9iIn0.1-JbNe1xNoHJgPEmJ05oULi4I9OMGBsviWFHnFPvm-I' --data-raw '{
  "@type": "Conversation",
  "title": "Nova conversa amb usuaris",
  "users": ["usuari1", "usuari2"]
}'

8.0 - Serialitzant el contingut

Guillotina proporciona mecanismes per serialitzar tot el contingut d'un objecte, tant les propietats de l'objecte com els behaviors, i també serialitzadors per al contingut de resum dels llistats.

Per crear un serialitzador personalitzat per un tipus, necessitem proporcionar un multi adapter per les interficies IResourceSerializeToJsonSummary i IResourceSerializeToJson.

Pel nostre cas, volem assegurar-nos d'incloure la data de creació i altres dades en la serialització del resum de la conversa i del missatge.

Definim un serializador personalitzat

Crearem un nou arxiu anomenat serialize.py

from guillotina import configure
from guillotina.interfaces import IResourceSerializeToJsonSummary
from guillotina.json.serialize_content import DefaultJSONSummarySerializer
from guillotina.utils import get_owners
from guillotina_chat.content import IConversation, IMessage
from zope.interface import Interface


@configure.adapter(
    for_=(IConversation, Interface),
    provides=IResourceSerializeToJsonSummary)
class ConversationJSONSummarySerializer(DefaultJSONSummarySerializer):
    async def __call__(self):
        data = await super().__call__()
        data.update({
            'creation_date': self.context.creation_date,
            'title': self.context.title,
            'users': self.context.users
        })
        return data


@configure.adapter(
    for_=(IMessage, Interface),
    provides=IResourceSerializeToJsonSummary)
class MessageJSONSummarySerializer(DefaultJSONSummarySerializer):
    async def __call__(self):
        data = await super().__call__()
        data.update({
            'creation_date': self.context.creation_date,
            'text': self.context.text,
            'author': get_owners(self.context)[0]
        })
        return data

Recorda que hem d'afegir l'arxiu perquè guillotina el pugui detectar a __init__.py

configure.scan('guillotina_chat.serialize')

NOTA: Quan modifiquem guillotina, els canvis no s'actualitzen en calent, és a dir, hem de parar el servidor i tornar-lo a arrancar per veure els canvis aplicats.

Ara si fem una petició GET a un objecte del tipus Conversation podrem veure com en la serialització del resum dels missatges, en el camp items, hi ha la informació que hem definit en el serialitzador, com el camp author.

9.0 - Serveis

Els serveis a guillotina són com en els altres frameworks els endpoints o views. En el nostre cas utilitzarem el servei per obtenir les converses i els missatges més recents d'un usuari en una conversa.

Creant els serveis

Crearem els serveis @conversations i @messages en l'arxiu services.py

from guillotina import configure
from guillotina.component import get_multi_adapter
from guillotina.interfaces import IContainer, IResourceSerializeToJsonSummary
from guillotina.utils import get_authenticated_user_id
from guillotina_chat.content import IConversation


@configure.service(context=IContainer, name='@conversations',
                   permission='guillotina.AccessContent')
async def get_conversations(context, request):
    results = []
    conversations = await context.async_get('conversations')
    user_id = get_authenticated_user_id()
    async for conversation in conversations.async_values():
        if user_id in getattr(conversation, 'users', []):
            summary = await get_multi_adapter(
                (conversation, request),
                IResourceSerializeToJsonSummary)()
            results.append(summary)
    results = sorted(results, key=lambda conv: conv['creation_date'])
    return results


@configure.service(context=IConversation, name='@messages',
                   permission='guillotina.AccessContent')
async def get_messages(context, request):
    results = []
    async for message in context.async_values():
        summary = await get_multi_adapter(
            (message, request),
            IResourceSerializeToJsonSummary)()
        results.append(summary)
    results = sorted(results, key=lambda mes: mes['creation_date'])
    return results

NOTA: Quan modifiquem guillotina, els canvis no s'actualitzen en calent, és a dir, hem de parar el servidor i tornar-lo a arrancar per veure els canvis aplicats.

Recorda que hem d'afegir-ho en el fitxer __init__.py

configure.scan('guillotina_chat.services')

Aquests serveis accedeixen a la base de dades i retornen els resultats per tu. Aquí podem veure algunes interaccions amb els objectes i la base de dades amb els serveis, tot i això pots fer el mateix utilitzant el punt d'entrada @search.

El servei @conversations hi accedirem des del container de l'aplicació, en canvi, el servei @messages hi accedirem des de qualsevol objecte del tiups Conversation per obtenir tots els missatges d'aquest.

Per utilitzar el punt d'entrada @search assegura't de tenir l'aplicació guillotina.contrib.catalog.pg en la configuració en l'arxiu config.yaml

Per exemple pots obtenir totes les converses fent una petició GET amb el punt d'entrada @search

GET @search?type_name=Conversation

i per obtenir tots els missatges

GET @search?type_name=Message

També podem fer cerques per text i filtrant per dates

GET @search?type_name=Message&text=foobar&creation_date__gte=2023-01-13T15:30:27.580369+00:00

10.0 - Utilitats asíncrones

Una utilitat asíncrona és un objecte asíncron que s'executa permanentment a l'event loop d'asyncio. Són útils per tasques llargues. Per la nostra aplicació utilitzarem una tasca asíncrona per enviar missatges als usuaris que han iniciat sessió.

Crea l'arxiu utility.py

from guillotina.async_util import IAsyncUtility
from guillotina.component import get_multi_adapter
from guillotina.interfaces import IResourceSerializeToJsonSummary
from guillotina.utils import get_authenticated_user_id, get_current_request

import asyncio
import orjson
import logging

logger = logging.getLogger('guillotina_chat')


class IMessageSender(IAsyncUtility):
    pass


class MessageSenderUtility:

    def __init__(self, settings=None, loop=None):
        self._loop = loop
        self._settings = {}
        self._webservices = []
        self._closed = False

    def register_ws(self, ws, request):
        ws.user_id = get_authenticated_user_id()
        self._webservices.append(ws)

    def unregister_ws(self, ws):
        self._webservices.remove(ws)

    async def send_message(self, message):
        summary = await get_multi_adapter(
            (message, get_current_request()),
            IResourceSerializeToJsonSummary)()
        await self._queue.put((message, summary))

    async def finalize(self):
        self._closed = True

    async def initialize(self, app=None):
        self._queue = asyncio.Queue()

        while not self._closed:
            try:
                message, summary = await asyncio.wait_for(self._queue.get(), 0.2)
                for user_id in message.__parent__.users:
                    for ws in self._webservices:
                        if ws.user_id == user_id:
                            await ws.send_str(orjson.dumps(summary))
            except (RuntimeError, asyncio.CancelledError, asyncio.TimeoutError):
                pass
            except Exception:
                logger.warning(
                    'Error sending message',
                    exc_info=True)
                await asyncio.sleep(1)

Les utilitats asíncrones han d'implementar el mètode initialize. En el nostre cas estem creant una cua i esperant per processar els missatges. Cada nou missatge rebut, l'enviarem pels websockets que tinguem registrats amb el mateix usuari.

Tal com hem fet amb els altres mòduls de configuració, afegirem l'arxiu en el __init__.py A més a més, hem de definir la utilitat en el app_settings en el mateix arxiu __init__.py

app_settings = {
    "load_utilities": {
        "guillotina_chat.message_sender": {
            "provides": "guillotina_chat.utility.IMessageSender",
            "factory": "guillotina_chat.utility.MessageSenderUtility",
            "settings": {}
        },
    }
}

Enviant missatges

Necessitarem afegir un nou subscriptor d'esdeveniments en el nostre 'subscribers.py' per enviar els nous missatges a la utilitat, i processar-los cap als websockets registrats. Ara el nostre arxiu subscribers.py quedarà de la següent manera:

from guillotina import configure
from guillotina.component import get_utility
from guillotina.interfaces import IObjectAddedEvent, IObjectModifiedEvent, IPrincipalRoleManager
from guillotina.utils import get_authenticated_user_id, get_current_request
from guillotina_chat.content import IConversation, IMessage
from guillotina_chat.utility import IMessageSender


@configure.subscriber(for_=(IConversation, IObjectAddedEvent))
@configure.subscriber(for_=(IConversation, IObjectModifiedEvent))
async def container_added(conversation, event):
    user_id = get_authenticated_user_id()
    if user_id not in conversation.users:
        conversation.users.append(user_id)

    manager = IPrincipalRoleManager(conversation)
    for user in conversation.users:
        manager.assign_role_to_principal(
            'guillotina_chat.ConversationParticipant', user)


@configure.subscriber(for_=(IMessage, IObjectAddedEvent))
async def message_added(message, event):
    utility = get_utility(IMessageSender)
    await utility.send_message(message)

11.0 - Websockets

Guillotina ja integra suport pels Websockets per defecte. És tan senzill com utilitzar un websocket d'asgi en un servei.

Crea un arxiu anomenat wb.py

from guillotina import configure
from guillotina.component import get_utility
from guillotina.interfaces import IContainer
from guillotina.transactions import get_tm
from guillotina_chat.utility import IMessageSender

import asyncio
import logging

logger = logging.getLogger('guillotina_chat')


@configure.service(
    context=IContainer, method='GET',
    permission='guillotina.AccessContent', name='@conversate')
async def ws_conversate(context, request):
    ws = request.get_ws()
    await ws.prepare()

    utility = get_utility(IMessageSender)
    utility.register_ws(ws, request)

    tm = get_tm()
    await tm.abort()
    try:
        async for msg in ws:
            # ws does not receive any messages, just sends
            pass
    finally:
        logger.debug('websocket connection closed')
        utility.unregister_ws(ws)
    return {}

Aquí utilitzarem utility = get_utility(IMessageSender) per obtenir la nostra utilitat asíncrona que hem definit prèviament, tot seguit registrarem el nostre webservice amb utility.register_ws(ws, request).

El nostre servei és simple, ja que no ens cal rebre cap missatge i la utilitat ja els envia.

Utilitzant els websockets

Per utilitzar els websockets primer necessitem obtenir un token. Els tokens dels websockets estan encriptats amb jwk. Guillotina no proporciona per defecte un token jwt, així que n'haurem de generar un:

guillotina gen-key

Un cop el tenim, afegirem el token en la configuració en l'arxiu config.yaml

jwk:
  k: Jg2oze4sTlw0QtLAF6vPGpygfUdzFLa_MufKKBWgvXE
  kty: oct

Seguidament, utilitzarem el punt d'entrada @wstoken per obtenir un token per iniciar el websocket.

curl --location --request GET 'http://localhost:8080/db/container/@wstoken' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NzQyNTE5MDYsImV4cCI6MTY3NDI1NTUwNiwiaWQiOiJib2IiLCJzdWIiOiJib2IifQ.-w1hchfQ1Nd-D4Un4adu6qPyD7vHM_e8c-wNltMv5QQ' \
--data-raw ''

Finalment, utilitzem aquest token per generar la URL (Aquí tenim un exemple amb Javascript):

var url = 'ws://localhost:8080/db/container/@conversate?ws_token=' + ws_token;
SOCKET = new WebSocket(url);
SOCKET.onopen = function(e){
};
SOCKET.onmessage = function(msg){
  var data = JSON.parse(msg.data);
};
SOCKET.onclose = function(e){
};

SOCKET.onerror = function(e){
};

12.0 - Arxius estàtics

Guillotina ens permet servir arxius estàtics. Només hem de definir en la configuració quina és la ruta on tenim els arxius a servir, i contra quina ruta es podran consultar.

app_settings = {
    ...
    "static": {
        "static_web": "guillotina_chat:static"
    },
}

Aquí estem definint que en l'aplicació guillotina_chat en la carpeta static hi ha els arxius, i els servirem via la url /static_web. En la nostra aplicació crearem una petita aplicació amb javascript.

Copiem els següents arxius en una nova carpeta static en la nostra aplicació:

Modifica l'objecte app_settings de l'arxiu __init__.py

app_settings = {
    "load_utilities": {
        "guillotina_chat.message_sender": {
            "provides": "guillotina_chat.utility.IMessageSender",
            "factory": "guillotina_chat.utility.MessageSenderUtility",
            "settings": {}
        },
    },
    "static": {
        "static_web": "guillotina_chat:static"
    },
}

Per veure la web podríem accedir a la URL http://localhost:8080/static_web.

13.0 - Conclusions

Ara ja hem vist com podem crear una aplicació amb guillotina, creant els nostres propis content types, utility, utilizant els addons assignant els permisos i utilitzant-la amb una petita aplicació JavaScript.

Pots veure el codi de l'aplicació en el repositori de Github

En un següent article crearem una interfície gràfica web per poder gestionar tots els objectes de la nostra aplicació.