DEV Community

Jorge Eψ=Ĥψ
Jorge Eψ=Ĥψ

Posted on • Originally published at jorge.aguilera.soy on

Groogle Raffle

Idea

mn-raffle es (otra) implementación de un sistema para sortear premios entre una serie de participantes,como por ejemplo los asistentes a una charla (presencial o virtual) usando GoogleSheet como repositorio para guardarlos datos de los participantes y del sorteo

A diferencia de la implementación google-raffle.html, puramente en javascript y embebida en una hoja GoogleSheet,en esta ocasión vamos a usar una aplicación web, desarrollada en Micronaut para el backend y Vue para el frontend

| | El código del proyecto se encuentra alojado en https://gitlab.com/groogle/mn-raffle |

| | La aplicación desplegada se encuentra en https://mn-raffle-pvidasoftware.cloud.okteto.net/ |

| | En este post NO voy a describir exhaustivamente paso a paso cómo se ha desarollado la aplicación, sinolas partes que considero más interesantes |

Básicamente, un usuario organizador de un sorteo accederá a la aplicación vía web la cual, através de llamadas REST solicitará datos de participantes, premios, etc contra el backend.

El backend a su vez no tendrá nada de persistencia sino que delegará en Google Sheet la misma.El usuario deberá haberse identificado ante el sistema usando así mismo el mecanismo deautentificación de Google.

Arquitectura

diag 67519beae77171541f4ee0c6d2ca22d3

Como se puede ver en el diagrama, la aplicación se divide en los siguientes componentes:

  • Una aplicación Vue (Javascript y HTML) que correrá en el navegador del usuario

  • Un backend REST en Micronaut que devuelve la lista de participantes, premios y al que sele indica quién ha ganado qué.

  • Google: GoogleSheet como persistencia y GoogleAuth como autentificación de usuarios (para futuras funcionalidades que lo requieran)

La aplicación constará de 2 módulos (client y server) que podrán ser desarrollados de formaindependiente pero que se empaquetarán como un artefacto único para ser desplegado.

Proyecto

Como ya se ha mencionado, el proyecto va a ser un multi-module de Gradle, client y backend.

diag ad7f45157c449483bc979bea9c6444d1

Client

Para la parte cliente (Vue) lo normal es usar gulp, o herramientas similares para construirlo, sin embargo gracias alos plugins npm que tenemos en gradle, vamos a usarlo tanto para construir la parte cliente como la parte server yde esta forma tener una única tarea capaz de construir ambos y juntarlos en un único artefacto a desplegar.

Por lo demás el proyecto semilla lo crearemos mediante vue-cli siguiendo los tutoriales disponibles en la páginaoficial de Vue siendo relevante que usaremos:

  • bootstrap-vue para la parte visual

  • route para enrutar las diferentes partes de la aplicación

  • vuex para gestionar el estado de la misma

Así mismo crearemos un interface service que sirva para dialogar con el backend con una implementación fake a usar en eldesarrollo del cliente y evitar la necesidad de tener el backend levantado

Backend

El server será una aplicación micronaut típica con los siguientes componentes:

  • micronaut security, en concreto usaremos Google para la autentificación

  • groogle-sheet, un DSL que nos permite acceder a una hoja Google de forma fácil

Debido a que el backend accederá a servicios remotos en Google,deberemos de crear un proyecto en Google Cloud Platform para obtener unas credenciales de servicio.Descargaremos el fichero JSON que las contiene pero nos aseguraremos que NO se versiona junto al código

Para ejecutar el server y poder acceder a la hoja de cálculo que nos indique el usuario deberemos tener una variablede entorno GOOGLE_APPLICATION_CREDENTIALS apuntando a la ruta completa del fichero JSON anterior.

Google

La persistencia se va a realizar utilizando GoogleSheet.

El organizador del sorteo podrá crear tantos documentos y hojas como desee, correspondiendocada una de ellas a un sorteo.

En la hoja el organizador dispondrá de los nombres de los participantes en una columna,los premios a sortear en otra junto con la cantidad de cada uno de ellos y el sistema anotará a qué participantele ha tocado qué premio.

Si se desea se puede proporcionar un email por cada participante para notificarle vía correoque ha sido agraciado con un premio.

La imagen siguiente es un ejemplo con las filas y columnas a utilizar en cada hoja:

mn groogle

| | Para que el backend pueda acceder al documento este deberá ser compartido con la cuenta de servicio que secreó en Google Cloud Console. |

Backend

Como se ha comentado el backend será una aplicación Micronaut ofreciendo un API:

diag 43163a1c8a858e3fb35210a7a9243b74

Mediante UserController el front podrá obtener detalles del organizador del sorteo (previa autentificación del mismousando Google como proveedor de autentificación) como el nombre y el email. Sólo se usa para para fines estadísticosde uso.

RaffleController es el encargado de ofrecer la lista de participantes así como de precios disponibles en cada momento.Requiere para ello que se le indique el id de la hoja de Google así como el nombre del tab que se quiere usar paraese sorteo.

Así mismo acepta que tras un sorteo se le indique quién ha salido ganador y aceptado el premio o bien si no estápresente para no volver a ofrecerlo en la lista de participantes.

Para que el backend pueda acceder a la hoja indicada el propietario de esta deberá haberla compartido con unacuenta de servicio creada para ello a través de las opciones de compartir disponible en la propia hoja de GoogleSheet.

Raffle usa el DSL 'Groogle' para leer y escribir en la hoja. Por ejemplo, para leer la lista de participantes:

List<Participant> loadParticipants(String sheetId, String tabId){
    List people = []

    sheetService.withSpreadSheet sheetId, {
        withSheet tabId, {
            writeRange"A3", "C99", {
                get().eachWithIndex{ def entry, int i -> (1)
                    if( entry[0] && !entry[1])
                        people.add new Participant(name:entry[0], email: entry[2])
                }
            }
        }
    }
    people.sort { Math.random() }
}
Enter fullscreen mode Exit fullscreen mode

| 1 | Leemos un rango de filas-columnas y rellenamos un array con aquellas que tienen datos |

Para escribir en la hoja, por ejemplo si un participante no ha acudido y no volver a ofrecerlo, el código sería:

 Boolean notPressent(String sheetId, String tabId, String name){
    sheetService.withSpreadSheet sheetId, {
        withSheet tabId, {
            List<List<String>> range = writeRange("A3", "C99").get() (1)
            List<String> rwinner = range.find{ it[0] && it[0] == name}
            rwinner[1] = "NOT PRESSENT"
            writeRange("A3","C99").set(range) (2)
        }
    }
    true

}
Enter fullscreen mode Exit fullscreen mode

| 1 | Leemos un rango determinado |
| 2 | escribimos un array en la hoja |

Como se ha mencionado, el backend contendrá a su vez el build del client como recursos incluidos en el propio jar.De esta forma evitamos requerir otro componente para servir la parte html+javascript

Client

El client va a consistir en una aplicación Vue (con route+state y bootstrap-vue para la parte visual)

Al ser un submodulo del proyecto Gradle, podemos usar el mismo IDE a la vez que mantenemos alineados los dos proyectos.

Lo único que neceitamos para ello es establecer "un puente" entre Gradle y npm para lo que usaremos el pluginorg.ysb33r.nodejs.npm de Gradle y crearemos unas task especificas para construir y ejecutar la parte cliente

plugins {
    id 'org.ysb33r.nodejs.base' version '0.6.2'
    id 'org.ysb33r.nodejs.npm' version '0.6.2'
}

task npmInstall( type : org.ysb33r.gradle.nodejs.tasks.NpmTask ) {
    group 'build'
    description = 'Install dependencies'
    command 'install'
}

task start( type : org.ysb33r.gradle.nodejs.tasks.NpmTask ) {
    group 'build'
    description = 'Run the client app'
    command 'run'
    cmdArgs 'serve'
}

//others task
Enter fullscreen mode Exit fullscreen mode

State

La aplicación va a consistir en unos estados muy simples

src/store/index.ts

class State {

    busy = false;

    user = {
        name:'',
        email:''
    };

    googleForm = {
        sheetId:'',
        tabId:''
    };

    participants = [];
    prizes = [];

    winner = '';
    prize = '';
}
Enter fullscreen mode Exit fullscreen mode

con unas actions también simples:

src/store/index.ts

    actions: {
    //....
        fetchParticipants( context ){
            return api
                .loadParticipants(context.state.googleForm)
                .then((participants: any) => context.commit('participants', participants))
        },
    //....
        raffle( context, prize ){
            const arr = context.state.participants
            const winner = arr[Math.floor(Math.random() * arr.length)]
            context.commit('prize', prize)
            context.commit('winner', winner['name'])
        },
        acceptWinner( context){
            return api
                .winner( context.state.googleForm, context.state.prize, context.state.winner)
                .then( () => context.dispatch('fetchPrizes') )
                .then( () => context.dispatch('fetchParticipants') )
        },
        notPressent(context){
            return api
                .notPressent( context.state.googleForm, context.state.winner)
                .then( () => context.dispatch('fetchParticipants') )
        }
    },
Enter fullscreen mode Exit fullscreen mode

Usando la configuración por entorno de Vue podremos indicar al store qué implementación de API usar:

env.development

VUE_APP_API_CLIENT = 'mock'
Enter fullscreen mode Exit fullscreen mode

env.production

VUE_APP_API_CLIENT = 'server'
Enter fullscreen mode Exit fullscreen mode

Service

Para poder desarrollar el front sin depender de tener corriendo el backend, vamos a usar un mock que devolverádatos de pruebas contenidos en un JSON con un cierto retraso, simulando que se está realizando una petición http:

src/services/mock/index.ts

const user = mock.user
const prizes = mock.prizes
const vparticipants = mock.participants

const mockFetchData = (mockData: any, time = 0) => {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(mockData)
        }, time)
    })
}

export default new class {
    loadParticipants(groogleData: any){
        return mockFetchData(vparticipants, 1000) // wait 1s before returning posts
    }
    // others methods
}
Enter fullscreen mode Exit fullscreen mode

Build and deploy (Okteto)

Una vez que queramos desplegar una nueva versión simplemente ejecutaremos ./gradlew assembleServerAndClient la cualpreparará un jar con los dos componentes.

Así mismo mediante el plugin jib de Gradle podremos generar y subir una imagen Docker con la aplicación a DockerHub(o a tu repositorio).

Como plataforma donde desplegar la aplicación he elegido Okteto (https://okteto.com) el cual cuenta con un plan gratuitomuy generoso donde ejecutar este tipo de aplicaciones usando Kubernetes.

Para kubernetizar la aplicación simplemente usamos el fichero que nos creó micronaut (con algunos ajustes)

k8s.yml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: "mn-raffle"
spec:
  selector:
    matchLabels:
      app: "mn-raffle"
  template:
    metadata:
      labels:
        app: "mn-raffle"
    spec:
      volumes:
      - name: google-cloud-key
        secret:
          secretName: mn-raffle-key
      containers:
        - name: "mn-raffle"
          image: "jagedn/mn-raffle"
          imagePullPolicy: "Always"
          volumeMounts:
            - name: google-cloud-key
              mountPath: /var/secrets/google
          env:
            - name: GOOGLE_APPLICATION_CREDENTIALS
              value: /var/secrets/google/client_secret.json
            - name: MICRONAUT_SECURITY_OAUTH2_CLIENTS_GOOGLE_CLIENT_SECRET
              valueFrom:
                configMapKeyRef:
                  name: mn-raffle
                  key: google_client_secret
            - name: MICRONAUT_SECURITY_OAUTH2_CLIENTS_GOOGLE_CLIENT_ID
              valueFrom:
                configMapKeyRef:
                  name: mn-raffle
                  key: google_client_id
          ports:
            - name: http
              containerPort: 8080
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            timeoutSeconds: 3
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            timeoutSeconds: 3
            failureThreshold: 10
---
apiVersion: v1
kind: Service
metadata:
  name: "mn-raffle"
  annotations:
    dev.okteto.com/auto-ingress: "true"
spec:
  selector:
    app: "mn-raffle"
  type: ClusterIP
  ports:
    - port: 8080
      protocol: TCP
      targetPort: 8080
Enter fullscreen mode Exit fullscreen mode

mediante el comando `kubectl apply -f k8s.yml

(como se puede observar las credenciales se encuentran guardadas en un configMap dentro del kubernetes)

Latest comments (0)