Javascript Fullstack: Restana-Riot-Loki

Alrededor de 2014 se empezaba a sentir la utilidad que prestaba el desarrollo con Javascript con un papel más protagónico cubriendo diversos aspectos. En ese momento, era popular en esa comunidad de entusiastas, un proyecto Fullstack combinando tecnologías como MongoDB para contar con una base de datos que utilizaba de modo natural JSON, Express para el servidor web, AngularJS en su primera versión para la vista y por supuesto Node.js, usándose el acrónimo MEAN (MongoDB + Express + AngularJS + Node.js). En el escenario propuesto que se ha venido presentando, en lugar de Express es posible usar Restana, en lugar de Angular usamos RiotJS y la base de datos puede variar, iniciándo con una tecnología sencilla como LokiJS que no requiere instalación de un servidor y tiene semejanzas con la API de MongoDB.

Si se pregunta por qué usar Restana o Riot, la respuesta simple es que al analizar o armar tu propio rompecabezas sientes que una pieza no encaja hasta que encuentras la apropiada para ese rompecabezas. Por supuesto que existen criterios técnicos, logísticos, antecedentes u otros, pero eso se presta para demasiadas opiniones al confrontarlo con otras tecnologías muy interesantes y no para lo que estás logrando en tu propio recorrido de aprendizaje o en un proyecto como OnMind. Dado que Restana y Riot son librerías sencillas de implementar respecto a otras, puedes darte la oportunidad de tomar tu propia opinión. Pienso que en éste contexto suelo buscar algo que coincide con una frase atribuida a Albert Einstein que dice:

“hazlo tan simple como sea posible, pero no más”

Apliquemos ahora conocimientos adquiridos si ya los tienes o se ha seguido la serie de artículos propuestos por OnMind (Enfoques para Programar,Javascript, Javascript bajo Node.js, RiotJS). El proyecto corresponderá a un directorio de contactos sencillo.

Procura evitar copiar y pegar. Quizás copia los archivos de configuración pero en cuanto al código es importante reconocer cada línea de código como si la hubieses escrito aunque el código ya se encuentre listo. Re-escribir o digitar código de éste proyecto de práctica puede porporcionar una manera apropiada de asimilar el aprendizaje sobre éste tema.

Los archivos del proyecto

Para comprender mejor la disposición de nuestros archivos, tendríamos lo siguiente:

Básicamente tendríamos dos proyectos. Nuestro proyecto front-end se llamará gui y nuestro proyecto back-end se llamará api. El “script” principal de gui se distribuirá con el nombre app.bundle.js, mientras el “script” principal de api se distribuirá con el nombre server.bundle.js. Los archivos estáticos distribuibles de gui quedarían dentro de la carpeta api/run/assets.

Para la base de datos se usará en este ejercicio LokiJS. Además, dado que procesaremos peticiones en JSON se necesitará para ello el módulo body-parser. Estos módulos se podrían instalar con npm install -S lokijs body-parser, sin embargo, esta vez tendrémos preparado nuestro archivo package.json para api, así:

{
  "scripts": {
    "start": "webpack --mode development",
    "build": "webpack --mode production",
    "serve": "nodemon run/server.bundle.js"
  },
  "dependencies": {
    "body-parser": "^1.19.0",
    "lokijs": "^1.5.7",
    "restana": "^3.3.2",
    "serve-static": "^1.14.1"
  },
  "devDependencies": {
    "@babel/core": "^7.6.3",
    "@babel/preset-env": "^7.6.3",
    "babel-loader": "^8.0.6",
    "nodemon": "^1.19.4",
    "webpack": "^4.41.1",
    "webpack-cli": "^3.3.9",
    "webpack-node-externals": "^1.7.2"
  }
}

Para instalar los paquetes respectivos simplemente nos ubicamos en la carpeta del proyecto api y ejecutamos npm install.

Del mismo mode tendrémos preparado nuestro package.json para gui, así:

{
  "scripts": {
    "start": "webpack --mode development",
    "build": "webpack --mode production"
  },
  "dependencies": {
    "riot": "^4.6.5"
  },
  "devDependencies": {
    "@riotjs/compiler": "^4.5.1",
    "@riotjs/webpack-loader": "^4.0.1",
    "copy-webpack-plugin": "^5.0.4",
    "webpack": "^4.41.1",
    "webpack-cli": "^3.3.9"
  }
}

Para instalar los paquetes respectivos simplemente nos ubicamos en la carpeta del proyecto gui y ejecutamos npm install.

El archivo de configuración webpack.config.js para api tendrá el siguiente contenido:

const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  entry: './src/api.js',
  output: {
    path: path.resolve(__dirname, 'run'),
    filename: 'server.bundle.js'
  },
  target: 'node',
  externals: [nodeExternals()],
  node: {
    console: false,
    __dirname: false,
    __filename: false
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
        }
      }
    ]
  }
}

El archivo de configuración webpack.config.js para gui tendrá el siguiente contenido:

const path = require('path');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
  entry: './src/gui.js',
  output: {
    path: path.join(__dirname, '..', 'api', 'run', 'assets', 'dist'),
    filename: 'app.bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.riot$/,
        exclude: /node_modules/,
        use: [{
          loader: '@riotjs/webpack-loader',
          options: { hot: false }
        }]
      }
    ]
  },
  plugins: [
    new CopyWebpackPlugin([
        { from: './src/index.html', to: '..' }  // output.path & back to 'assets'
    ])
  ]
}

Comenzando con el back-end, para api tendremos como “script” principal api.js que gestiona el servidor web y los micro-servicios con el siguiente código:

const files = require('serve-static');
const restana = require('restana');
const bodyParser = require('body-parser');
const db = require('./dbaccess');
const service = restana();
const port = 3000;

service.use(bodyParser.json());
service.use(bodyParser.urlencoded({ extended: true }));
service.use(files(__dirname + '/assets'));

service.post('/mydata/', db.insert);
service.get('/mydata/', db.list);
service.delete('/mydata/:id', db.remove);

service.start(port).then((server) => {
    console.log(`Service listening at ${port}`);
});

Nótese que aquí es se requiere un archivo adicional para el proyecto con nombre dbaccess.js, el cual gestiona los datos usando LokiJS, como se puede observar a continuación:

const path = require('path');
const loki = require('lokijs');

const dbFile = path.join(__dirname, 'database.json');
const db = new loki(dbFile, {
    autoload: true,
    autosave: true, 
    autosaveInterval: 4000
});

const save = (data) => {
    var count = 0
    var result = {}
    try {
        var mydata = db.getCollection('mydata');
        count = mydata.count();
        console.info(`Data in ${dbFile}: ${count}`);
        mydata.on('insert', (row) => { row.id = row.$loki; });  // set auto id
        result = mydata.insert(data);
        db.saveDatabase();
    }
    catch (e) {
        if (count == 0) {  // Create the collection the first time (auto-create)
            var mydata = db.addCollection('mydata');
            mydata.on('insert', (row) => { row.id = row.$loki; });  // set auto id
            result = db.getCollection('mydata').insert(data);
        }
        else {
            console.error(e.message,count,result)
            throw e
        }
    }

    return result;
}

exports.insert = async function (req, res, next) {
    var data = req.body.data;
    var result = save(data);
    res.send(result);
}

exports.list = async function (req, res, next) {
    try {
        var result = db.getCollection('mydata');
        res.send(result.data);
    }
    catch (e) {
        res.send({ success: false, message: 'Not data found, try insert!', extra: e.message });
    }
}

exports.remove = async function (req, res, next) {
    var id = req.params.id;
    var data = db.getCollection('mydata');
    var result = data.where((row) => { return row.id == id });  // or data.find({ 'id': id })
    data.remove(result);
    res.send({ success: true, message: `${id} removed!` });
}

Nótese que se usa exports en Node.js para indicar que una función en particular es de acceso público y puede ser invocada como una librería. También podrían listarse al final tales funciones expresándose como exports { insert, list, remove }
Podemos lanzar nuestro servidor ubicándonos en api, primero ejecutamos npm start y luego npm run serve (o, cd run y node server.bundle.js). Es posible que en tiempo de desarrollo se necesiten abrir otras sesiones de terminal.

Continuando con el front-end, para gui tendremos el archivo index.html usado en un proyecto anterior pero en la nueva ubicación, es decir, la siguiente plantilla:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Riot Component</title>

    <!-- Semantic-UI Styles -->
    <link href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.5.0/semantic.min.css" rel="stylesheet" media="screen">
    <!-- HighlightsJS -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.9/styles/ocean.min.css">
  </head>
  <body>
    <div id="app"></div>

    <script src="https://code.jquery.com/jquery-3.6.1.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.5.0/semantic.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.9/highlight.min.js"></script>
    <script defer src="dist/app.bundle.js"></script>
  </body>
</html>

El “script” principal gui.js que invoca nuestro componente web tendrá siguiente código:

import { component } from 'riot'
import Tag from './my-data.riot'

component(Tag)(document.getElementById('app'), {})

Veamos ahora el contenido del archivo my-data.riot:

<my-data>
    <div class={ state.color }>
        <br/>
        <div class="ui text container">
            <h1 class="ui { state.color == 'dark' ? 'yellow' : 'blue' } header">
                <i class="address book icon"></i>
                <div class="content">
                    My Directory
                </div>
            </h1>
            <form class="ui form" onsubmit={ onSend } autocomplete="off">
                <div class="field">
                    <div class="two fields">
                        <div class="field">
                            <input type="text" name="name" placeholder="First Name" required>
                        </div>
                        <div class="field">
                            <input type="text" name="lastname" placeholder="Last Name">
                        </div>
                    </div>
                </div>
                <button class="ui right floated icon button">
                    <i class="save blue icon"></i>
                </button>
                <button class="ui right floated icon button" onclick={ onList }>
                    <i class="refresh icon"></i>
                </button>
            </form>
            <div class="ui toggle checkbox">
                <input type="checkbox" onchange={ onCheck }>
                <label><i class="adjust orange icon"></i></label>
            </div>
            <br/>
            <div class="ui basic tiny label">
                <i class="world icon"></i> { state.baseURI }
            </div>
            <div class="ui placeholder segment" id="holder" if={ (state.status == 0) }>
                <div class="ui icon header">
                    <a onclick={ onList }><i class="search blue link icon"></i></a>
                    Try it!
                </div>
            </div>
            <table class="ui { state.color == 'dark' ? 'inverted' : '' } selectable unstackable table" if={ (state.status == 1) }>
                <thead>
                    <tr>
                        <th>Name</th>
                        <th>Last Name</th>
                        <th></th>
                    </tr>
                </thead>
                <tbody>
                    <tr each={ row in state.data }>
                        <td>{ row["name"] }</td>
                        <td>{ row["lastname"] }</td>
                        <td class="collapsing">
                            <button class="ui basic icon button" onclick={ onRemove } data-id={ row["id"] }>
                                <i class="trash red icon"></i>
                            </button>
                        </td>
                    </tr>
                </tbody>
            </table>
            <pre if={ (state.status == -1) }>
                <code style="max-height: 300px; overflow-y: scroll;">{ state.code }</code>
            </pre>
        </div>
    </div>

    <style>
        .clear {
            background-color: white;
            color: black;
            height: 100vh;
        }

        .dark {
            background-color: dimgray;
            color: snow;
            height: 100vh;
        }
    </style>

    <script>
        export default {
            state: {
                status: 0,  // 0 => initial, 1 => ok, -1 => error
                name: 'john',
                color: 'dark',
                data: [],
                baseURI: 'http://localhost:3000/mydata/'
            },
            onMounted() {  // When component is mounted or created
                this.update()
            },
            onSend(e) {
                e.preventDefault()
                this.update({ status: 1, code: '...' })  // update or render again HTML (DOM)
                let form = document.forms[0]  // get the first form or unique
                let name = form['name'].value
                let lastname = form['lastname'].value

                if (!name) {
                    this.update({ code: 'Name wrong!' })
                    return
                }

                let data = {
                    "name": name,
                    "lastname": lastname
                }
                let options = {
                    method: 'POST',
                    body: JSON.stringify({ "data": data }),
                    headers: { 'Content-Type': 'application/json' }
                }

                fetch(this.state.baseURI, options)
                .then(res => res.json())
                .then(data => {
                    form['name'].value = null
                    form['lastname'].value = null
                    this.onList()
                })
                .catch(err => {
                    this.update({ status: -1, code: `${this.state.baseURI} => ${err.message}` })
                    hljs.initHighlighting()
                });
            },
            onList(e) {
                if (e) e.preventDefault()  // this "if" is because can be called whithout "e"
                this.update({ status: 1, code: '...' })  // update or render again HTML (DOM)

                fetch(this.state.baseURI)
                .then(res => res.json())
                .then(data => {
                    this.update({ code: JSON.stringify(data, null, '  '), data: data })
                })
                .catch(err => {
                    this.update({ status: -1, code: `${this.state.baseURI} => ${err.message}` })
                    hljs.initHighlighting()
                });
            },
            onRemove(e) {
                e.preventDefault()
                let target = e.target
                if (target.tagName == 'I')
                    target = e.target.parentNode
                
                // Get the "id" from element or target attribute
                let id = target.getAttribute('data-id')

                fetch(this.state.baseURI + id, { method: 'DELETE' })
                .then(res => res.json())
                .then(data => {
                    this.onList()
                })
                .catch(err => {
                    this.update({ status: -1, code: `${this.state.baseURI} => ${err.message}` })
                    hljs.initHighlighting()
                });
            },
            onCheck(e) {
                if (this.state.color == 'clear')
                    this.state.color = 'dark'
                else
                    this.state.color = 'clear'
                this.update()
            }
        }
    </script>
</my-data>

Ahora podemos generar los archivos distribuibles de gui ejecutando npm start y puedes consultar en el navegador la dirección respectiva, por ejemplo: localhost:3000 (asegúrate que este operando api)

Ajustes a nuestro front-end

Vamos a reorganizar por piezas o partes de nuestra vista destinando un sub-componente para el formulario (my-form.riot) y un sub-componente para la tabla (my-table.riot), quedando nuestra carpeta gui de la siguiente manera:

El código para my-form.riot correspondería al siguiente:

<my-form>
    <form class="ui form" onsubmit={ props.onsend } autocomplete="off">
        <div class="field">
            <div class="two fields">
                <div class="field">
                    <input type="text" name="name" placeholder="First Name" required>
                </div>
                <div class="field">
                    <input type="text" name="lastname" placeholder="Last Name">
                </div>
            </div>
        </div>
        <button class="ui right floated icon button">
            <i class="save blue icon"></i>
        </button>
        <button class="ui right floated icon button" onclick={ props.onlist }>
            <i class="refresh icon"></i>
        </button>
    </form>
</my-form>

El código para my-table.riot correspondería al siguiente:

<my-table>
    <table class="ui { props.color == 'dark' ? 'inverted' : '' } selectable unstackable table">
        <thead>
            <tr>
                <th>Name</th>
                <th>Last Name</th>
                <th></th>
            </tr>
        </thead>
        <tbody>
            <tr each={ row in props.data }>
                <td>{ row["name"] }</td>
                <td>{ row["lastname"] }</td>
                <td class="collapsing">
                    <button class="ui basic icon button" onclick={ props.onremove } data-id={ row["id"] }>
                        <i class="trash red icon"></i>
                    </button>
                </td>
            </tr>
        </tbody>
    </table>
</my-table>

Nuestro achivo my-data.riot se vería afectado en lo correspondiente a la plantilla (incluso, hasta un par de líneas del “script”), mientras la mayor parte del contenido del “script” queda intacta. Básicamente las líneas de la parte superior quedan de la siguente manera:

<my-data>
    <div class={ state.color }>
        <br/>
        <div class="ui text container">
            <h1 class="ui { state.color == 'dark' ? 'yellow' : 'blue' } header">
                <i class="address book icon"></i>
                <div class="content">
                    My Directory
                </div>
            </h1>
            <my-form onsend={ onSend } onlist={ onList } />
            <div class="ui toggle checkbox">
                <input type="checkbox" onchange={ onCheck }>
                <label><i class="adjust orange icon"></i></label>
            </div>
            <br/>
            <div class="ui placeholder segment" id="holder" if={ (state.status == 0) }>
                <div class="ui icon header">
                    <a onclick={ onList }><i class="search blue link icon"></i></a>
                    Try it!
                </div>
            </div>
            <my-table color={ state.color } data={ state.data } onremove={ onRemove } if={ (state.status == 1) } />
            <pre if={ (state.status == -1) }>
                <code style="max-height: 300px; overflow-y: scroll;">{ state.code }</code>
            </pre>
        </div>
    </div>

    <style>
        .clear { background-color: white; color: black; height: 100vh; }
        .dark { background-color: dimgray; color: snow; height: 100vh; }
    </style>

    <script>
        import MyForm from './my-form.riot'
        import MyTable from './my-table.riot'
        export default {
            components: { MyForm, MyTable },
            ... // same code inside of "export default"
        }
    </script>
</my-data>

Cuida de conservar el código que no se ve impactado (que se encuentra dentro export default), puesto que sigue como estaba. Se puede notar que se presenta una plantilla de una mejor manera y se pueden reutilizar esas partes en otras funciones sobre esos datos, por ejemplo, si se pensara en implementar la actualización de datos (actualmente se usaría eliminar y volver a insertar).

Recuerda volverlo a generar con npm start y que el servicio de api se encuentre disponible. Luego puedes consultar en el navegador la dirección respectiva, por ejemplo: localhost:3000


© 2019 by César Arcila