Teknisk Dokumentation - 1dv611-vt21-g5/1dv611-project GitHub Wiki

Innehåll

Funktionalitet

Det huvudsakliga use caset för vår applikation är att möjliggöra kommunikation mellan Sensatives Yggio IoT-plattform och automationsplattformen Zapier - tanken är att när t.ex. en IoT-sensor rapporterar att temperaturen ökat, skickas detta sedan till oss via webhook där vi parsear datan och skickar vidare den till Zapier, där en användare sedan tidigare satt upp en s.k. Zap som tar datan och skriver en tweet om det (eller vad som helst som går att göra via Zapier).

Tidigare flödesdiagram över hur appen fungerar finns tillgängliga här, det kanske kan vara intressant att se hur idéerna utvecklats över tid under genomförandet:

  • Flowdiagram över hur applikationens viktigaste Use Case-flöden ser (såg) ut.

  • Förklaringsmodell över hur backenden är uppbyggd i ren mapp-struktur. Denna är mer eller mindre fortfarande aktuell, men skapades innan vi påbörjade genomförandet för att enklare förstå hur Yggios SDK var uppbyggd (se Teknik).

Nedan beskriver hur appen fungerar generellt nu när den nästan är i färdigt skick, en uppdaterad version av våra tidigare flödesdiagram. Förklaringar med bilder nedanför:

Stor version

1. Configuration Flow

Beskriver hur man inledningsvis loggar in i vår plattform och aktiverar + konfigurerar IoT-sensorer man vill skicka vidare till Zapier.

Detta sker framförallt i vår frontend (för mer "kodnära" info se Data Update Flow) stegen ser ut så här (klicka för att förstora bilderna):

0. Välj vår app bland Yggios appar/integrationer:

1. Logga in via OAuth:

=>

2. IoT devices hämtas från Yggio:

3. Konfigurera en device, här kan man välja att ge den ett bättre displayName samt välja vilka datafält som faktiskt är intressanta (och byta namn även på dessa), sedan aktivera prenumerationen (detta skapar en s.k. Node i vår backend):

3a. Före

3b. Efter inställningar

3c. Efter aktivering

4. Kopiera API-nyckeln ur vårt UI och lägg till i Zapier för att de ska kunna autentisera sina API-anrop mot oss:

5. Skapa en Zap i Zapier som utnyttjar den nyskapade prenumerationen på en device:

5a. Välj vilken subscribad device att använda

5b. Verifiera att sample-datan ser ut att vara i rätt format

5c. Konfigurera en action med triggern, I det här fallet vill vi skapa en tweet

5d. Testa och aktivera Zappen (detta gör att Zapier skapar en webhook som sedan används i Device Updates Flow)

6. Klart! När Yggio uppdaterar med ny data behandlar vi detta och skickar sedan vidare till Zapier via Webhook (se Device Updates Flow) och Zappen körs.

2. Device Updates Flow

Beskriver vad som sker när man konfigurerat en IoT-device och Yggio-plattformen skickar en webhook med datauppdatering.

1. Data mottas från Yggio

När vi skapar en subscription i steg 3 av Configuration Flow ovan sker framför allt två saker:

  • Det skapas ett Node-objekt i vår databas där användarens konfigurationsinställningar sparas, ett Node-objekt kan se ut så här internt:
// typical Node
{
_id: ObjectId,
owner: ObjectId, // pekar mot en User
yggioId: ObjectId, // IoT-devicens ID hos Yggio
subscriptionId: ObjectId, // Subscription-webhookens ID hos Yggio (se nedan)
displayName: "Lux 1",
dataValues: [
  {
   name: "luminance",
   displayName: "Luminance",
   path: ["value", "luminance"]
  },
  {
   name: "temperature",
   displayName: "Temperature",
   path: ["value", "temperature"]
  },
],
... // + fler ointressanta fält
}
  • Det upprättas också en webhook i Yggios plattform som aktiveras när en device rapporterar ny data. Denna webhook pekar alltid mot vår applikation tillsammans med devicens id: ${process.env.BACKEND_URI}/api/updates/${yggioId}, vilket låter oss särskilja vilket device den nya datan gäller.

Dessa device updates kommer i formatet:

{
  payload: { 
    event: {...},
    diff: {...},
    iotnode: {...}
  }
}

Där iotnode innehåller ett snapshot av hela IoT-devicens state vid tillfället för uppdateringen, således är det egentligen bara den vi är intresserade av. Här är ett exempel på hur ett iotnode-objekt kan se ut live:

   "iotnode": {
      "_id": "60892972c07cb300069e4691",
      "nodeType": "SimpleDevice",
      "__v": 0,
      "category": "uncategorized",
      "createdAt": "2021-01-14T13:44:21.057Z",
      "description": "The device was successfully included, but we couldn't match the product information with a device name.",
      "deviceModelName": "N/A",
      "latlng": [],
      "name": "lux_1_6142341 Translated",
      "rabbitRouting": {},
      "reportedAt": "2021-05-16T22:16:25.178Z",
      "translatedFrom": "60004a913f93fa0006b57280",
      "updatedAt": "2021-05-16T22:16:25.202Z",
      "value": { "luminance": 0.54, "temperature": 45.6, "temperatureUnit": "celsius" },
      "version": 17
    }

2. Querya databasen efter aktiva subscriptions/Nodes

När en update kommer in, som i exemplet ovan, hämtar vi ut alla aktiva Nodes ur databasen för just denna device (via dess yggioId). Vi itererar sedan över dessa:

3. Omvandla data efter vad som finns i Node

För varje Node går vi igenom iotnode-objektet och plockar ut dem valda datapunkterna. Det är här dataValues i Noden kommer in, för varje dataValue använder vi en rekursiv funktion för att hämta ut den aktuella datan i det (ibland djupt nästlade) iotnode-objektet. Den funktionen är definierad så här:

/**
 * Iterates through a nested object step-by-step to return a value
 *
 * @param {object} object A nested object to parse, f.e. `{ a: { b: { c: 25 }}}`
 * @param {string[]} pathsArray An array of field names to step through, f.e. `['a', 'b', 'c']`
 * @returns The value of the final key in pathsArray, f.e. `25`
 */
const getNestedValue = (object, pathsArray) => {
  if (pathsArray.length === 1) {
    return object[pathsArray[0]]
  }
  const innerObject = object[pathsArray[0]]
  const innerArray = [...pathsArray.slice(1)] // returns prev array with first item removed
  return getNestedValue(innerObject, innerArray)
}

Rekursion valdes framför enklare dot- eller bracket-notering eller en reduce-baserad lösning för att enkelt kunna hantera både objekt och arrays av arbiträr längd. Den må vara i O(n) jämfört med enkel dot-noterings O(1), men det verkar även Lodash (object) _.get vara (som vi inte kände till när vi skrev koden) så det nöjer vi oss med.

Allting sammanställs i ett slutgiltigt update-objekt på detta vis:

    const update = {
      id: node.yggioId,
      name: node.displayName,
      data: {}
    }

    node.dataValues.forEach(dataValue => {
      update.data[dataValue.name] = { displayName: dataValue.displayName, value: getNestedValue(data, dataValue.path) }
    })

    // {
    //   id: ObjectId,
    //   name: 'Lux 1',
    //   data: {
    //     luminance: {
    //       displayName: 'Luminance',
    //       value: 0.54
    //     },
    //     temperature: {
    //       displayName: 'Temperature',
    //       value: 45.6
    //     }
    //   }
    // }

Vi är nu redo att skicka datan till Zapier.

4. Skicka data till Zapier

I steg 5 av Configuration Flow skapar användaren en Zap på Zapiers plattform, när den aktiveras skickar Zapier ett anrop till vår applikation om att skapa en ZapierHook (vice versa när en användare stänger av en Zap skickas ett anrop för att radera ZapierHooken).

En ZapierHook har denna strukturen:

{
  owner: ObjectId, // Pekar mot User
  target_url: String, // Pekar mot en URL hos Zapier
  deviceId: ObjectId // Pekar mot en device ID
}

När vi är färdiga med datan i steg 3 och är redo att skicka hämtar vi alla ZapierHooks med samma owner och deviceId som Noden (se Data Flow Hierarchy), vi itererar över dessa och gör POST-anrop till target_url med update-objektet som request body. Zapier tar sedan hand om det enligt hur Zapparna är upplagda och t.ex. tweetar ut datan.

3. Data Flow Hierarchy

Beskriver hierarkin i dataflödet - Yggio skickar sina updates per IoT-device, när den sedan landar hos oss behandlar vi den efter vad som står i varje Node, varje användare kan dock bara ha en Node per device (som det är just nu). Dem kan däremot använda samma Node för att trigga ett obegränsat antal Zaps och således ha ett obegränsat antal ZapierHooks per Node.

Detta leder i praktiken till en dubbelt nästlad loop när en device update kommer in, vilket kan vara bra att känna till:

// gravt förenklat, med felaktiga metodnamn, vi använder dessutom await Promise.all(map istället för forEach i riktiga appen

const sendUpdate = (deviceUpdate) => {
  const nodes = getNodes({ deviceId: deviceUpdate.deviceId })

  nodes.forEach(node => {
    const parsedData = parse(node, deviceUpdate)
    const zapierHooks = getHooks({ owner: node.owner, deviceId: node.yggioId })

    zapierHooks.forEach(hook => {
      http.POST(hook.target_url, parsedData) // till Zapier
    })
  })
}

Detta är kanske inte optimalt om appen skulle växa, många anrop innebär fler möjligheter för något att gå fel och vi har ingen möjlighet att återförsöka misslyckade anrop som det är uppbyggt nu. En möjlighet för att göra detta mer robust i framtiden är att implementera en "task/job queue" (t.ex. Bull) och ersätta logiken i updates-routen. I stället för att iterationen och POSTandet sker i själva routen skapar routen i stället bara en task med den info som behövs och skickar den till task-kön, som lever i en separat process. Där kan det sedan betas av efterhand, och om något skulle gå fel är det enkelt att försöka på nytt.

4. Frontend

Frontend-applikationen är i grunden ganska simpel med bara fyra egentliga use cases:

  1. Att kunna autentisera sig mot Yggio via OAuth (och logga ut)
  2. Möjlighet att konfigurera och skapa prenumerationer på IoT-devices hos Yggio (och avsluta dessa)
  3. Möjlighet att hämta en API-nyckel som kan användas hos Zapier (och resetta denna för att få en ny)
  4. Möjlighet att läsa kort om projektet

Detta speglar sig i implementationen där varje use case har en egen route:

  • / - För login med OAuth
  • /devices - För att se och konfigurera IoT-devices
  • /user - För att se sin användareinfo samt API-nyckel
  • /about - Där vi kort beskriver projektet och vilka vi är som gjort det

Detta är enkelt i Next.js, där varje .js-fil placerad i /pages-mappen automatiskt bildar en route beroende på vad den heter (/about heter då about.js, / heter index.js).

I stället för att använda en global state-lösning som Redux förlitar vi oss i stället på vårt datahämtningsbibliotek swr. swr har en inbyggd global cache för varje anrop den gör, vilket gör att man kan komma åt datan via deras inbyggda hooks även mellan anrop (man kan till och med använda en "hämta data" hook som man explicit säger enbart ska förlita sig på cache och aldrig prata med internet) - man slipper helt enkelt all boilerplate som det innebär att hantera och lagra hämtad data i state.

Sättet det är upplagt på gör även att man automatiskt får tillgång till ett anrops status i realtid (loading, error, etc.), och kan använda detta i sin komponent för att visa en spinner eller felmeddelande.

Relaterat till swr så använder vi axios för att göra "aktiva" requests mot vårt API, t.ex. POST, PUT, etc. Detta leder dock till ett problem: Hur uppdaterar vi UI:t när en POST om att t.ex. aktivera en subscription skickas ut, när swr är ett ganska passivt sätt att hämta data på och vi inte har en state store vi kan manipulera?

swr har bl. a. möjlighet att automatiskt "polla" efter ny data (om en subscriptionen är aktiv t.ex.) vid ett visst tidsintervall, men det kan verkar laggigt om intervallet är 5 sekunder och man aktiverar en subscription precis efter en uppdatering - att behöva vänta 4+ sekunder på att UI:t uppdaterar är inte så trevligt. För att få bukt på detta returnar varje swr-hook en funktion som heter mutate som man kan använda för att omedelbart mutera cachen samt aktivera en omhämtning. UI:t uppdaterar sig då omedelbart medan den väntar på "bekräftelse" från API:t.

Ett lite konstlat exempel på en React-komponent:

...
const { data, error, mutate} = useSWR('/devices/23')

if (error) return <ErrorMessage />
if (!data) return <LoadingSpinner />


const activateSubscription = async () => {
  await axios.put('/devices/23', { subscribed: true })
  mutate('/devices/23', {...data, subscribed: true})
}

return (
<div>
  <p>{data.subscribed}</p> // false till att börja med
  <button onClick={activateSubscription}>Subscribe</button>
</div>
)

// När knappen klickas ändras data.subscribed omedelbart till true medan swr gör ett anrop till
// API:t för att bekräfta. Om anropet gått igenom som det ska bekräftas subscribed: true och 
// det ligger kvar, men om något gått fel fångar omhämtningen detta och ställer tillbaka vår
// mutation till sitt egentliga värde i API:t (i det här fallet false) 

Förutom det har vi ett formulär för device-inställningar som är ganska komplicerat (och borde refaktoreras) och en del UX-relaterade grejer (relaterade till Chakra UI) som ev. är lite kluriga, men i övrigt är det en väldigt standard React-app arkitektur-mässigt.

5. Arkitektur

Översiktligt är applikationen uppbyggd i ett klient-server-förhållande, med en separat hostad frontend single-page-app som kommunicerar med ett REST API.

Backend

Till den mån man kan säga att vår applikation är uppbyggd med en specifik arkitektur i åtanke är nog MVC det närmsta (men vi är en bit ifrån). View-lagret består av JSON, och vi har definitivt Controllers, sedan blir det snårigt. Då vi började bygga backend-applikationen efter Yggios SDK fortsatte vi på deras mönster, med controllers uppdelade efter route snarare än efter model (SDKn använde inga egna models alls). Se Projektstruktur för bild på detta. Vi hade ingen egentlig plan när det gällde arkitekturen utöver det, inte heller några krav på att hålla oss till REST-principer.

Detta har ibland lett till förvirring över var viss funktionalitet "bör" ligga, bör inloggningsfunktionalitet ligga under /users eller /auth? Bör /auth ens existera? Den innehåller en hel del väldigt icke-RESTfulla routes och metoder. Hur modellerar man en rutt som Zapier kan anropa för att få "fejkad" device-data för en given device för att kunna visa i sitt gränssnitt? Skapa en ny controller och routes bara för /samples?

För ändamålet har det inte spelat någon roll, men för ev. framtida refaktorering bör det hållas i åtanke - applikationens struktur kan förenklas om man håller lite hårdare i principerna.

Frontend

Frontenden är, precis som nästan alla React-appar, baserad på Flux. Data flödar bara nedåt i komponentträdet, medan handlingar färdas uppåt. Detta kan konstrasteras med andra ramverk som Angular, där data kan flöda åt båda håll.

Teknik och verktyg

Vid projektets start fick vi reda på att Sensative erbjöd ett SDK för sin plattform, denna bestod egentligen av en enklare klient/server-applikation byggd med React och Express tillsammans med ett bibliotek för att kommunicera med deras API. För enkelhets skull valde vi att basera vår applikation på denna SDK, då alla gruppmedlemmar redan var bekanta med Express och ett flertal jobbat med React tidigare. Projektet är alltså helt implementerat i Javascript.

Vid närmare efterforskning visade det sig att frontend-klienten var byggd med en relativt utdaterad version av React, så vi valde att helt skrota den och omimplementera den med den senaste versionen av Next.js (och således senaste versionen av React). Detta för att ge oss tillgång till funktionalitet så som React hooks, Next.js grymma router och möjligheten att statiskt generera vissa routes.

För datalagring valdes MongoDB tillsammans med ODM-biblioteket Mongoose då alla gruppmedlemmar var väl bekanta med detta.

Övriga tekniker och verktyg som kommer användas finns länkade nedan med kort motivering. Klicka på ett verktyg eller teknik för att få reda på mer information:

Klientsidan

  • React - Ramverk på klientsidan

För att snabbt kunna skapa en detaljerad interaktiv frontend.

För dess router samt andra smidiga lösningar som underlättar vid utveckling av React-appar, t.ex. statisk generering, bild-optimering, etc.

Ett "utility-first" komponentramverk i samma anda som Tailwind CSS, kommer med bra defaults men är superenkelt att anpassa med hjälp av s.k. style props (<Box p="2rem" backgroundColor="red.400>Hej</Box> ger en div med 2 rem padding och röd bakgrundsfärg. Har bra inbyggda verktyg för att minska slutgiltig .css-storlek i appen, då den ansar bort oanvända attribut ur slutgiltiga js-bundeln.

  • SWR - Datahämntnings-bibliotek

I stil med Apollo Client eller React Query, hämtar data och använder en intern cache för att lagra denna och göra tillgänglig till hela appen, med automatisk (programmerbar) omhämtning. Tillåter oss att helt skita i globala state-lösningar som Redux, vi använder SWRs cache i stället.

  • axios - HTTP-request bibliotek

Används när vi vill göra "aktiva" HTTP-anrop, typ POST, PUT, etc. SWR lämpar sig enbart för GET. SWR har dock metoder man kan använda i anslutning till POST-requests för att uppdatera den interna cachen direkt medan man väntar på omhämtning. Leder till omedelbara resultat i UIn utan behöva mixtra med state själv.

Används i device-listan, både för att enkelt rendera hur den råa datan från en device ser ut, men även i formuläret där man kan välja datapunkter.

Serversidan

  • Express - Ramverk på serversidan

För enkelhets skull och för att SDK-appen redan var implementerad i Express

Tillhandahållet av Sensative, innehåller färdiga metoder för att interagera med Yggios API - inkl. access/refresh-token funktionalitet.

  • axios - HTTP-request bibliotek

Används när vi vill göra anrop utanför den vanliga request/response-cykeln, t.ex. när en device-uppdatering kommer in och vi ska POSTa data till alla webhooks.

Används för datalagring, vald eftersom alla i gruppen var bekväma med det och använt det tidigare.

Hosting

  • Netlify - Produktionssättning för klientsidan

Enkel "one-click" deploy via Git, har också generös gratis-tier

  • Heroku - Produktionssättning för serversidan

Samma som ovan :)

Utvecklingsverktyg

  • VS Code IDE - Som utvecklingsmiljö
  • Git - För versionshantering
  • Ngrok - I utvecklingssyfte, eftersom vi kör applikationen lokalt men ibland behöver göra oss tillgängliga för anrop på internet, webhooksen, etc.

Testning

  • ESLint - Kodstandard
  • Postman - Under utvecklingen och testning, för att simulera uppdateringar och annat
  • Jest - Testramverk

Dokumentation

Övrigt

  • Yggio API - För att få tag på data från Yggio-plattformen
  • Zapier - Som mellanhand i integrationen mellan Yggio och Twitter (eller andra plattformar)
  • OAuth - För inloggning

Stil

Stilen ska efterliknas referensen Lime i så stor utsträckning som möjligt.

För utvecklare

Klona git-repot

  1. Skapa ett konto via GitHub för att få tillgång till Ysocials organisation.

  2. Klona ner repot med hjälp av SSH-nyckel Lägg till SSH.

  3. I IDE-terminalen skriv kommandot git clone [email protected]:1dv611-vt21-g5/1dv611-project.git.

  4. Gå in i rätt katalog, skriv kommandot cd 1dv611-project i terminalen.

  5. Skapa en egen branch via din IDE, skriv i terminalen git checkout -b dev-DittNamn.

  6. Skriv kommandot git branch i terminalen för att kontrollera att du är i rätt branch.

Exempelutskrift:

*dev-DittNamn

master

Kör appen lokalt

  1. För att starta appen lokalt, för utvecklingssyfte, starta appen enligt följande kommandon:

cd 1dv611-project/backend

npm install

npm start

och

cd 1dv611-project/frontend

npm install

npm run dev

  1. Öppna webbläsaren, navigera till http://localhost:3000. Logga in med YGGIO_ACCOUNT_USERNAME och YGGIO_ACCOUNT_PASSWORD som finns i backend/src/config/common.js (kommer att flyttas till en .env fil).

Välj arbetsuppgifter

  1. Välj nuvarande iteration och välj en uppgift "To Do" i listan i Projects.

  2. Markera uppgiften som påbörjad (In Progress) i nuvarande iteration och arbeta med uppgiften.

  3. När du är klar, markera uppgiften som avslutad i Projects, i nuvarande iteration.

Uppdatera repo

  1. Använd git add . git commit -m 'passande text' och push -u origin dev-DittNamn föra att spara ändringar till din branch under arbetes gång.

  2. För att undvika konflikter måste en pull request skapas manuellt på Github och jämföras med master branch. Godkänn eller ändra.

Kommunikation

All kommunikation inom arbetsgruppen kommer ske via Slack, kanal: 1dv611-grupp-5.
Kommunikation via kund och Ysocial sker via mejlkorrespondens samt via en inbjudbar Slackkanal.

⚠️ **GitHub.com Fallback** ⚠️