Hapi.js + Vue.js inicializar el frontend

El backend ya está algo configurado, por lo que voy a empezar a configurar el frontend.

Instalaré varias librerías:

  • Vue.js
  • Webpack: configurado para que funcione con HMR
  • Eslint: para que no haya errores Javascript
  • Stylelint: lo mismo para CSS
  • Buefy: una librería que combina Bulma y Vue
  • Sass loader

En vez de ir instalando una a una, usando el siguiente package.json y ejecutando npm i, lo tendremos todo instalado.

{
  "name": "hapi-frontend",
  "version": "1.0.0",
  "description": "Hapi.js frontend",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "webpack-dev-server --mode development --config webpack.config.dev.js",
    "build": "webpack --mode production --config webpack.config.production.js"
  },
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.5.4",
    "babel-eslint": "^10.0.2",
    "babel-loader": "^8.0.6",
    "css-loader": "^3.0.0",
    "eslint": "^6.0.1",
    "eslint-plugin-html": "^6.0.0",
    "eslint-plugin-vue": "^5.2.3",
    "mini-css-extract-plugin": "^0.7.0",
    "node-sass": "^4.12.0",
    "sass-loader": "^7.1.0",
    "stylelint": "^10.1.0",
    "stylelint-config-standard": "^18.3.0",
    "stylelint-webpack-plugin": "^0.10.5",
    "vue-hot-reload-api": "^2.3.3",
    "vue-html-loader": "^1.2.4",
    "vue-loader": "^15.7.0",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.6.10",
    "webpack": "^4.35.3",
    "webpack-cli": "^3.3.5",
    "webpack-dev-server": "^3.7.2",
    "webpack-merge": "^4.2.1"
  },
  "dependencies": {
    "buefy": "^0.7.10",
    "vue": "^2.6.10"
  }
}

Como se puede ver, existen dos scripts dentro de npm: build que compila el js y extrae los CSS, y dev, que arranca el servidor de webpack habilitando HMR (🎶 ¡ya no puedo vivir sin él! 🎶).

Ambas configuraciones de webpack usan un script en común (webpack.config.common.js):

const webpack = require( 'webpack' );
const path = require( 'path' );
// Carga los ficheros .vue
const VueLoaderPlugin = require( 'vue-loader/lib/plugin' );
// Configura stylelint
const StyleLintPlugin = require( 'stylelint-webpack-plugin' );

// Para obtener un path para los alias
function resolve( dir ) {
	return path.join( __dirname, '.', dir );
}

module.exports = {
	mode: 'production',
	// Fichero inicial del proyecto
	entry: './js/main.js',
	// Fichero final para incluir
	output: {
		filename: 'js/main.js',
		publicPath: '/dist/',
	},
	module: {
		// Reglas para los ficheros
		rules: [
			{
				test: /\.js$/,
				exclude: /node_modules/,
				loader: 'babel-loader',
			},
			{
				test: /\.vue$/,
				loader: 'vue-loader',
			},
			{
				test: /\.css$/,
				use: [
					'css-loader',
					'sass-loader',
				],
			},
		],
	},
	plugins: [
		new webpack.HotModuleReplacementPlugin(),
		new VueLoaderPlugin(),
		new StyleLintPlugin( {
			files: [ '**/*.{vue,htm,html,css,sss,less,scss,sass}' ],
		} ),
	],
	resolve: {
		extensions: [ '.js', '.vue', '.json' ],
		alias: {
			'@': resolve( '' ),
		},
	},
};

El frontend se gestiona desde el fichero main.js, que inicializará Vue y añadirá el componente principal:

import Vue from 'vue';
import Buefy from 'buefy';
import 'buefy/dist/buefy.css';

import App from './components/App.vue';

import '@/assets/scss/main.scss';

Vue.use( Buefy );

new Vue( {
	el: '#app',
	components: {
		App,
	},
	render: ( c ) => c( 'app' ),
} );

// accept replacement modules
if ( module.hot ) {
	module.hot.accept();
}

Y ya por último el componente App.vue, que muestra simplemente un poco de HTML

<template>
	<header class="hero">
		<div class="hero-head">
			<h1>{{ title }}</h1>
		</div>
	</header>
</template>

<script>
export default {
	data() {
		return {
			title: "Demo site",
		};
	},
};
</script>

<style lang="scss" scoped>
	div {

		h1 {
			color: #fff;
		}
	}
</style>

Bueno, ha sido un resumen rápido, pero bajándote el código seguro que lo entiendes fácil

Hapi.js + Vue.js modelos mejorados

En este caso voy a explicar cómo añadir una ruta en el backend para gestionar usuarios.

Antes de nada vamos a instalar tres paquetes:

  • @hapi/joi para validar los datos de entrada
  • @hapi/boom para mostrar errores HTTP de forma sencilla
  • bcrypt para encriptar la contraseña
npm i @hapi/joi
npm i @hapi/boom
npm i bcrypt

Tendremos que añadir la ruta al manifest de glee

const manifest = {
	server: {
		port: Config.get( '/server/port' ),
	},
	register: {
		plugins: [
			{
				plugin: './api/home',
			},
			{
				plugin: './api/user',
			},
			{
				plugin: './plugins/db',
				options: Config.get( '/db' ),
			},
		],
	},
};

Ahora solo falta crear el controlador para las rutas de usuarios, dos en este caso:

  • GET /user/[user] para recuperar un usuario
  • PUT /user para crear un nuevo usuario

Lógicamente aún no hay nada de autenticación, por lo que cualquiera puede crear un usuario realizando una llamada PUT a la URL indicando userName, email y password.

Para comprobar la validez de los datos introducidos, usaremos joi. Usando las opciones de la ruta, indicaremos las reglas que deberá cumplir cada parámetro introducido. Así, para recuperar un usuario, se comprobará que user sea string, alfanumérico y que tenga una longitud de 3 a 20 caracteres:

validate: {
	params: {
		user: Joi.string().alphanum().min( 3 ).max( 20 ),
	},
},

Así de fácil.

En el caso de comprobar los datos de entrada de la llamada PUT, en vez de usar params, usaremos payload:

validate: {
	payload: {
		userName: Joi.string().alphanum().min( 3 ).max( 20 ).required(),
		email: Joi.string().email().required(),
		password: Joi.string().min( 8 ).required(),
	},
},

Por último mostrar el código para crear un nuevo usuario. Primero se comprueba si existe un usuario con ese nickname o email. Si es así, se devuelve error usando boom, si no, se genera la contraseña encriptada (aquí no me he molestado mucho en ello, ya lo haré más adelante), y se crea el usuario usando el método create de moongose:

/**
 * Route handler
 *
 * @param {object} request
 * @param {object} h Hapi object
 * @returns {object}
 */
handler: async( request, h ) => { // eslint-disable-line
	try {
		// TODO: Add role
		const user = await User
			.findOne( {
				$or: [
					{ userName: request.payload.username },
					{ email: request.payload.email },
				],
			} )
			.exec();
		if ( user ) {
			return Boom.badData( 'User exists' );
		}
		const password = await bcrypt.hash( request.payload.password, Config.get( '/hash/PASSWORD_HASH' ) );
		const userData = Object.assign( {}, request.payload, { password } );
		const newUser = await User.create( userData );

		return newUser ?
			{
				response: true,
				message: 'User created',
				userId: newUser.id,
			} :
			Boom.boomify( {
				response: false,
				message: 'There was an error during user creation',
			}, { statusCode: 400 } );
	} catch ( error ) {
		return Boom.badImplementation( 'Error', { error } );
	}
},

Como último apunte, he modificado la configuración para que admita ficheros .env con los datos necesarios.

Como siempre te puedes bajar el código aquí

Hapi.js + Vue.js accediendo a mongodb

Como ya dije, esta aplicación estará basada en Mongodb, y usaremos mongoose como ODM.

El primer paso es instalar mongoose:

npm i mongoose

Una vez instalado crearemos un controlador que nos permita usar la BD en toda la aplicación Hapi.js. Para ello haremos uso de los decorate del servidor. Los decorations permite extender objectos ofrecidos por Hapi.js, en nuestro caso server y request. Usando un plugin nos conectaremos a mongodb usando mongoose y añadiremos ese objecto con los decorate.

Creamos el fichero /plugins/db.js con el siguiente código:

/**
 * DB controller
 *
 * It uses Mongoose and "stores" it in the server and the request using `decorate`
 */
const mongoose = require( 'mongoose' );

exports.plugin = {
	name: 'db',
	register: async function( server, options ) {
		mongoose.connect( options.url, { useNewUrlParser: true } );
		const db = mongoose.connection;
		// eslint-disable-next-line
		db.on( 'error', console.error.bind( console, 'connection error:' ) );
		db.once( 'open', function() {
			server.decorate( 'server', 'db', mongoose );
			server.decorate( 'request', 'db', mongoose );
			// eslint-disable-next-line
			console.log( 'DB connected' );
		} );
	},
};

Para configurar la conectividad a mongodb tendremos que añadir los datos a la /config/index.js

const config = {
	server: {
		port: 3001,
	},
	website: {
		name: `WP Desk`,
	},
	db: {
		url: 'mongodb://localhost/wpdesk',
	},
};

Y en el manifiest usado por glue, tendremos que añadir el nuevo plugin y las nuevas opciones de conexión:

const manifest = {
	server: {
		port: Config.get( '/server/port' ),
	},
	register: {
		plugins: [
			{
				plugin: './api/home',
			},
			{
				plugin: './plugins/db',
				options: Config.get( '/db' ),
			},
		],
	},
};

Ya tenemos casi todo configurado, ahora vamos a empezar con un ejemplo creando un esquema de moongose que nos permite acceder a colecciones de mongodb.

Lo más común es tener una colección de usuarios, que tendrá los siguientes campos:

  • userName: de tipo String,
  • firstName: de tipo String,
  • lastName: de tipo String,
  • email: de tipo String,
  • role: que referencia a otro elemento de otra colección,
  • isEnabled: de tipo Boolean,
  • password: de tipo String,
  • resetPassword: un objeto representado por:
    • hash: de tipo String,
    • active: de tipo Boolean,

También crearemos un método estático que devuelva todos los elementos de la colección users para realizar pruebas:

/**
 * User model based on Mongoose
 */
const mongoose = require( 'mongoose' );
const Schema = mongoose.Schema;

// Mongoose schema
const userSchema = new mongoose.Schema( {
	userName: String,
	firstName: String,
	lastName: String,
	email: String,
	role: Schema.Types.ObjectId,
	isEnabled: Boolean,
	password: String,
	resetPassword: {
		hash: String,
		active: Boolean,
	},
} );

/**
 * User static model findAll
 *
 * @returns {array}
 */
userSchema.static( 'findAll', async function() {
	const result = await new Promise( ( resolve, reject ) => {
		this.model( 'User' ).find( {} ).exec( ( error, data ) => {
			if ( error ) {
				reject( error );
			}
			resolve( data );
		} );
	} );
	return result;
} );

const User = mongoose.model( 'User', userSchema );

module.exports = User;

Ya está todo, ahora solo modificamos el handler de la ruta home.js para mostrar los valores de findAll:

/**
 * Route handler
 *
 * @param {object} request
 * @param {object} h Hapi object
 * @returns {object}
 */
handler: async( request, h ) => { // eslint-disable-line
	try {
		const result = await User.findAll();
		return result;
	} catch ( error ) {
		return { error: 500 };
	}
},

Puedes bajarte el código aquí

Hapi.js + Vue.js empezando con las routes

Empezamos a mostrar contenido, hasta ahora solo mostraba un error 404 cuando se accedía a la URL del servidor.

Ahora vamos a añadir una route simple que muestre un objeto JSON cuando se acceda a la home. Recordad que la parte del servidor solo va a devolver respuestas JSON que tratará el frontend.

Usaremos plugins para tratar todas las rutas que incluiremos en glue. En este ejemplo tan solo añadiremos un route al server que devuelva un mensaje:

const Config = require( '../config' );

const websiteName = Config.get( '/website/name' );

const register = function( server, serverOptions ) { // eslint-disable-line
	server.route( {
		method: 'GET',
		path: '/',
		options: {
			tags: [ 'api', 'home' ],
			description: 'Server home',
			notes: 'Server home',
			auth: false,
		},
		handler: function( request, h ) { // eslint-disable-line
			return {
				message: `Welcome to ${ websiteName }`,
			};
		},
	} );
};

module.exports = {
	name: 'api-home',
	dependencies: [],
	register,
};

Ahora solo falta añadir el plugin en el manifest.js

const manifest = {
	server: {
		port: Config.get( '/server/port' ),
	},
	register: {
		plugins: [
			{
				plugin: './api/home',
			},
		],
	},
};

Puedes bajarte el código aquí

Hapi.js+Vue.js reorganizar la configuración del servidor

Algo bastante importante en un proyecto es la configuración y cómo se gestiona. Para facilitar la gestión usaremos dos librerías dotenv y confidence, la primera permite usar ficheros .env en nuestro entorno de desarrollo para simular variables de entorno. La segunda nos ayudará a recuperar las variables de un objeto permitiendo usar filtros, por ejemplo según de las variables de entorno.

Instalaremos los paquetes:

npm i dotenv
npm i confidence

Confidence necesitará un criterio que nos permitirá obtener distintos resultados según su valor. Imaginemos que tenemos el siguiente criterio:

const criteria = {
	env: 'development',
};

Y estos datos de configuración:

{
	debugLevel: {
		$filter: 'env',
		development: INFO,
		production: ERROR,
	},
}

Si queremos acceder al nivel de debug, al ser env igual a development, obtendíamos INFO.

Vale, ¿y cómo lo usamos en el proyecto? Primero creamos una carpeta config, donde crearemos el fichero index.js que tendrá toda la configuración del servidor:

const Confidence = require( 'confidence' );
const Dotenv = require( 'dotenv' );

Dotenv.config( { silent: true } );

// NODE_ENV is used in package.json for running development or production environment
const criteria = {
	env: process.env.NODE_ENV,
};

const config = {
	port: 3001,
};

const store = new Confidence.Store( config );

exports.get = function( key ) {
	return store.get( key, criteria );
};

exports.meta = function( key ) {
	return store.meta( key, criteria );
};

Dotenv simplemente se usa para obtener de las variables de entorno de servidor el valor de NODE_ENV. Por ahora solo tendremos la variable port, pero ya estará preparado para poder añadir otras variables de configuración posteriormente.

Creamos un store de Confidence y exportaremos los métodos get y meta.

Haremos algo parecido para el manifest necesario para Glue, creando el fichero manifest.js dentro del directorio config:

const Confidence = require( 'confidence' );
const Config = require( './index' );

const criteria = {
	env: process.env.NODE_ENV,
};

const manifest = {
	server: {
		port: Config.get( '/port' ),
	},
};

const store = new Confidence.Store( manifest );

exports.get = function( key ) {
	return store.get( key, criteria );
};

exports.meta = function( key ) {
	return store.meta( key, criteria );
};

Como se puede apreciar fácilmente obtenemos el valor de port de forma bastante simple.

Y por último modificamos el fichero index.js para hacer eso de estos nuevos ficheros:

const Glue = require( '@hapi/glue' );
const Manifest = require( './config/manifest' );

const options = {
	relativeTo: __dirname,
};

const startServer = async function() {
	try {
		const manifest = Manifest.get( '/' );
		const server = await Glue.compose( manifest, options );
		await server.start();
		console.log( 'hapi days!' ); // eslint-disable-line
	} catch ( err ) {
		console.error( err ); // eslint-disable-line
		process.exit( 1 );
	}
};

startServer();

Puedes bajarte el código aquí

Hapi.js+Vue.js Crear servidor backend

El primer paso va a ser crear el entorno del servidor, para ello creamos un directorio e inicializamos el proyecto ejecutando:

npm init

Rellenamos todos los datos que nos van pidiendo para configurar el proyecto.

Ya tenemos el proyecto creado, ahora iremos instalando las librerías que necesitamos, en este caso hapi y glue.

npm i @hapi/hapi
npm i @hapi/glue

¿Qué es glue? Glue es un plugin que permite configurar el servidor de forma fácil.

Ahora creamos el fichero index.js e insertamos el siguiente código:

const Glue = require( '@hapi/glue' );

const manifest = {
	server: {
		port: 3001,
	},
};

const options = {
	relativeTo: __dirname,
};

const startServer = async function() {
	try {
		const server = await Glue.compose( manifest, options );
		await server.start();
		console.log( 'hapi days!' ); // eslint-disable-line
	} catch ( err ) {
		console.error( err ); // eslint-disable-line
		process.exit( 1 );
	}
};

startServer();

Fácil de entender, ¿no? Creamos el servidor usando Glue con el manifest y las opciones, y arrancamos el servidor.

Listo, accedemos a http://localhost:3001 para comprobar que funciona. Nos devolverá error 404 porque por ahora no hay definidas routes.

¿Cómo se arranca el servidor? podríamos usar directamente node, pero mejor usaremos nodemon. Primero lo instalamos:

npm i --save-dev nodemon

Y ejecutaremos lo siguiente para arrancar el servidor:

npm run start

Puedes obtener el código en GitHub hapi-vue-demo 0.1.0

Crear aplicación web con Hapi.js, Vue y MongoDB

Estoy aprendiendo Hapi.js y Vue.js y qué mejor que ir anotando aquí lo que voy aprendiendo, y si a alguien le sirve, mejor que mejor.

Pretendo crear una aplicación web e ir explicando en diferentes posts, que iré añadiendo aquí, lo que estoy aprendiendo.

He estado viendo distintos boilerplates y el que más me ha gustado ha sido appy. Podría usarlo, pero prefiero aprender poco a poco que pegarme con algo ya hecho e indagar qué hace cada cosa.

Lo que más me gusta de appy es que diferencia entre el backend y el frontend de forma bastante clara, distintos directorios y aplicaciones para cada cosa. Es por ello que voy a seguir esta estructura. Aquí hay un artículo bastante majo que explica más o menos lo mismo.

Pues nada más, iré subiendo posts sobre lo que voy aprendiendo. Eso sí, porfa, si meto la pata en algo, no me lo tengas muy en cuenta, estoy aprendiendo.

  1. Hapi.js+Vue.js Crear servidor backend (código v0.1.0)
  2. Reorganización de la configuración (código v0.2.0)
  3. Empezar añadiendo routes (código v0.3.0)
  4. Conectando a mongodb (código v0.4.0)
  5. Modelos mejorados y controladores (código v0.5.0)
  6. Inicializando el frontend (código v0.6.0)

Añadir números de línea en highlight.js

Aprovechando la versión 1.2.0 de Snippet Block, comparto cómo añadir números de línea al código formateado por highlight.js.

El desarrollador del plugin pasa de añadir esta característica a la librería, lo cual me parece muy bien, que cada uno haga lo que quiera con su código. Pero como tampoco hay eventos o algo que permita añadir características, es necesario “hackear” la función antes de su uso.

document.addEventListener( 'DOMContentLoaded', () => {
	const prevHighligth = window.hljs.highlightBlock;
	window.hljs.highlightBlock = block => {
		const newline = '<span class="sw_new_line"></span>';
		// Replace all without regex
		block.innerHTML = block.innerHTML
			.split( newline )
			.join( '' );
		prevHighligth( block );
		if ( block.classList.contains( 'sw_show_line_numbers' ) ) {
			block.innerHTML = newline +
				block
					.innerHTML
					.replace( /\n/g, `\n${ newline }` );
		}
	};
} );

¿Qué es lo que hace este código? Primero guarda el método original highlightBlock para usarlo posteriormente. Y luego se modifica el método para apadir una clase para mostrar los números de línea al principio del código y después de cada salto de línea.

Ahora toca explicar la parte CSS, para ello usaremos CSS counters.

pre code {
	counter-reset: linenumber;
}

pre code .sw_new_line::before {
	content: counter(linenumber, decimal-leading-zero);
	counter-increment: linenumber;
	padding-right: 15px;
	font-size: 0.8em;
	opacity: 0.6;
}

Testear páginas web con AVA y PhantomJS

Estoy colaborando junto a @jlantunez y @belelros en WebSlides, un proyecto open source que permite crear presentaciones usando un navegador web de forma increíble.

Para hacer las cosas bien, vamos a incluir pruebas de testing, y para ello he mirado cómo hacerlo con AVA y PhantomJS. El problema con el que me he encontrado ha sido que todo es asíncrono y a veces da un poco de problemas esperar a cargar la página para que AVA empiece a realizar las pruebas.

Además no es plan de cargar la página en cada una de las pruebas, por lo que cargo la página una vez y luego realizo las pruebas necesarias. Esto me obliga a que las pruebas sean secuenciales en vez de en paralelo, pero bueno, tampoco es mucho problema.

Como me he roto la cabeza intentado averiguar cómo hacer, ya que soy un tanto novato en esto, pongo el código para aquel que lo necesite, aunque bueno, en breve estará en GitHub:


// Cargo las librerías
let phantom = require("phantom");
import test from 'ava';

// Para almacenar lo que PhantomJS necesita
let ph_, page_, status_;

// Función que carga la página
const load = async () => {
  await phantom.create().then(async ph => {
    ph_ = ph;
    return await ph_.createPage();
  }).then(page => {
    page_ = page;
    return page_.open('http://webslides.tv/');
  }).then(status => {
    status_ = status;
    return true;
  }).catch(e => console.log(e));
}

// Tests
test.serial("Page loaded", async t => {
  await load();
  t.is(status_, 'success');
});

test.serial('#webslides exits', async t => {
  await page_
    .evaluate( () => document.querySelector('#webslides') != null )
    .then( ws => { t.truthy(ws); } );
});

test.serial('WebSlides object exits', async t => {
  await page_
    .evaluate( () => window.ws != null )
    .then( ws => { t.truthy(ws); } );
});

/**
 * Last test
 */
test.serial('Closing', async t => {
  await page_.close();
  ph_.exit();
  t.true(true);
});

Incrustar un SVG dentro del HTML directamente

Lo bueno de los SVG es que puedes modificarlos mediante CSS directamente en el HTML, el único problema es que el SVG debe estar incrustado en el HTML, no mediante una imagen <img>, ni mediante la propiedad content de CSS.

Encontré un script muy bueno que te modifica una etiqueta img que contiene un SVG por su contenido SVG real. El problema viene cuando yo añado el SVG como una propiedad content:

.class {
  content: url('imagen.svg');
}

Para ello he modificado el script anterior para que coja la propiedad content de los selectores CSS que se indiquen y añada el SVG directamente al elemento:

jQuery(document).ready(function() {
  var replaceWithSVG = function(selector, url) {
    jQuery('').appendTo('head');
    jQuery.get(url, function(data) {
      // Replace image with new SVG
      jQuery(selector).prepend(data.replace(/<\?xml[^>]+>/, ''));
    }, 'text');
  }
  var selectors = ['.site-title a', '.btn-video']
  for(var i in selectors) {
    var selector = selectors[i];
    var url = window.getComputedStyle(
    	document.querySelector(selectors[i]), ':before'
    ).getPropertyValue('content');
    url = url.replace(/url\(["']?([^'"\)]+)["']?\)/, '$1');
    replaceWithSVG(selectors[i], url);
  }
});