23. März 2017 // von Manuel Ernst

Contentful, Frontend (III / III)

Dieser Artikel beschäftigt sich mit der Implementierung einer Webseite die auf das Datenschema aus Blogpost Eins aus der Contentful Reihe und den hinterlegten Daten aus dem zweiten Blogpost basieren.

Setup

Um eine Verbindung zur API von Contentful aufbauen zu können ist als erstes ein API Token notwendig. Über den Eintrag "API" im Menü können alle generierten Schlüssel eingesehen und gegebenenfalls ein neuer Schlüssel angelegt werden.

Datenlogik

Die Implementierung unseres Frontends wird auf die Servertechnologie node.js basieren. Von Contentful wird für diese Plattform ein SDK zur Verfügung gestellt welches unter https://www.npmjs.com/package/contentful eingesehen werden kann.

In einem neuen Projekt wird zuerst das SDK mittels des Packagemanagers "npm" installiert:

$ npm install contentful --save

Das SDK kann nun im Code verwendet werden. Mit Hilfe der SpaceId und des generierten Access Tokens wird ein Client erzeugt, dieser stellt verschiedene Methoden zur Verfügung mit denen Daten aus der Contentful abgerufen werden können.

const contentful = require('contentful')

const SPACE_ID = '8xyxd7613mdgd'
const ACCESS_TOKEN = 'f33e454a91091502a482b8121c9b36b3ab3'

//contentful client
const client = contentful.createClient({
    space: SPACE_ID, 
    accessToken: ACCESS_TOKEN
})

Die wichtigste Funktion des Contentful Clients ist wohl die Funktion getEntries. Sie erwartet ein Konfigurationsobjekt welches beschreibt nach welchen Kriterien eine Abfrage durchgeführt werden soll und gibt ein Promise zurück welches aufgelöst wird sobald die Anfrage von der API beantwortet wurde. Promises sind eine mögliche Herangehensweise um das asynchrone Programmiermodell von node.js abzubilden, eine alternative Möglichkeit wären zum Beispiel Callbacks.

Mittels eines einfachen Statements lassen sich nun alle Einträge vom Contenttyp "Blogpost" von der API abrufen:

client.getEntries({content_type: 'blogPost'})
    .then(result => console.log(result))

Entsprechend den erfassten Daten aus dem vorhergehenden Artikel sieht die API Antwort dann folgendermaßen aus (aus Platzgründen wird hier nur der erste Eintrag gezeigt und verschiedene Werte verkürzt dargestellt). Jedes Antwort Objekt enthält verschiedene Metadaten zu der Antwort an sich (beispielsweise wie viele Ergebnisse insgesamt gefunden wurden, ob es Einschränkungen bezüglich der Anzahl der ausgegebenen Ergebnisse gibt etc.), zu den verschienden Ergebnissen werden zusätzlich Informationen mitgegeben die die einzelnen Einträge betreffen, zum Beispiel das Erzeugungsdatum, den Zeitpunkt der letzten Änderung, den ContentTyp eines Eintrags usw.

{
  sys: {type: 'Array'},
  total: 4,
  skip: 0,
  limit: 100,
  items: [
    {
      sys: {
        space: {
          sys: {
            type: 'Link', 
            linkType: 'Space', 
            id: '8xyid523mdgd'
          }},
          id: '3pBJkppIxqs0MY6ewe0cME',
          type: 'Entry',
          createdAt: '2017-03-06T13:59:22.526Z',
          updatedAt: '2017-03-06T14:05:47.788Z',
          revision: 2,
          contentType: {sys: {
            type: 'Link', 
            linkType: 'ContentType', 
            id: 'blogPost'
          }},
          locale: 'de-DE'
        },
        fields: {
          title: 'The second entry',
          featureImage: {
            sys: {
              space: {sys: {
                type: 'Link', 
                linkType: 'Space', 
                id: '8xyid523mdgd'
              }},
              id: '15iSnmHVUiqesCu0k6iAKg',
              type: 'Asset',
              createdAt: '2017-03-06T13:58:54.974Z',
              updatedAt: '2017-03-06T13:58:54.975Z',
              revision: 1,
              locale: 'de-DE'
            },
            fields: {
              title: 'Drumset thomann',
              file: {
                url: '//images.[...]drumset.jpg',
                details: {
                  size: 66509, 
                  image: {width: 617, height: 600}
                },
                fileName: 'drumset.jpg',
                contentType: 'image/jpeg'
              }
            }
          },
          teaserText: 'The second [...] things',
          body: '__Waistcoat [...], ethical mixtape.\n',
          tags: ['important', 'interesting', 'contentful'],
          slug: 'the-second-entry'
        }
      },
      [...]
  ]
}

Für die Darstellung im Frontend sind die generellen Metadaten nicht interessant, weshalb wir uns einen kleinen Parser bauen um die Blogpostdaten auf das wesentliche zu reduzieren.

Die Funktion parseBlogPost nimmt die Daten eines einzelnen Blogposts an (im Beispiel eingeordnet in die Liste unter dem Key items) und gibt ein Objekt zurück welche nur die relevanten Informationen eines Blogposts enthält.

function parseBlogPost(blogPostData) {
    let fields = blogPostData.fields

    return {
        title: fields.title,
        teaserText: fields.teaserText,
        featureImage: parseImage(fields.featureImage),
        body: fields.body,
        tags: fields.tags,
        slug: fields.slug
    }
}

In der obigen Funktion wird die Method parseImage verwendet. Da aus der Contentful API eingebettete Resourcen als seperate Objekte übergeben werden ist notwendig für diese verlinkten Einträge eine seperate Funktion zu implementieren mit deren Hilfe die Daten aus dem FeatureImage Eintrag extrahiert werden:

function parseImage(image) {
    return {
        title: image.fields.title,
        url: image.fields.file.url
    }
}

Zu guter Letzt fehlt noch ein Funktion die das gesamte API Ergebnis mit allen Blogposts entgegennimmt und den BlogPost Parser auf jeden Eintrag anwendet:

function parseAPIResponse(apiResponse) {
    return apiResponse.items.map(entry => parseBlogPost(entry))
}

Der geparste Output könnte dann so aussehen:

[
  {
    title: 'The second entry',
    teaserText: 'The second entry is about interesting things',
    featureImage: {
      title: 'Drumset thomann',
      url: '//images.conte[...]et_thomann.jpg'
    },
    body: '__Waistcoat church-key occupy__ [...] mixtape.\n',
    tags: [ 'important', 'interesting', 'contentful' ],
    slug: 'the-second-entry'
  },
  [...]
]

Aufällig ist nun dass das BlogPost Body Feld noch in seiner ursprünglichen Form vorliegt, d.h. alle Formatierungen die im Contentful Backend vorgenommen worden sind erscheinen in ihrer Markdown Repräsentation. Da wir dabei sind eine Webseite zu erstellen installieren wir aus dem Package Manager npm das Modul marked, es sorgt dafür dass die Markdown Notation in HTML umgewandelt wird:

$ npm install marked --save

Wir passen den BlogPost Parser minimal an so dass der BlogPost Bodyinhalt umgewandelt wird:

const marked = require('marked')

function parseBlogPost(blogPostData) {
    let fields = blogPostData.fields

    return {
        title: fields.title,
        teaserText: fields.teaserText,
        featureImage: parseImage(fields.featureImage),
        body: marked(fields.body),
        tags: fields.tags,
        slug: fields.slug
    }
}

Der fertig geparste Inhalt enthält nun HTML Auszeichnungen:

[
  {
    "title": "The second entry",
    "teaserText": "The second [...] things",
    "featureImage": {
      "title": "Drumset thomann",
      "url": "//images.contentful.com/[...]t_thomann.jpg"
    },
    "body": "\u003cp>\u003cstrong>Waistcoat church-key 
             occupy\u003c/strong> subway tile wayfarers
             seitan craft beer jean shorts 
             farm-to-table, ugh irony chillwave hell 
             of.\n\u003ca href=\"https://www.craftbeer.de\">
             Craftbeer\u003c/a> hashtag activated charcoal 
             hell of cold-pressed affogato, ethical 
             mixtape.\u003c/p>\n",
    "tags": [
      "important",
      "interesting",
      "contentful"
    ],
    "slug": "the-second-entry"
  },
  [...]
]

Bisher haben wir nur die Liste aller erfassten BlogPosts aus der API abgerufen, auf dem gleichen Wege können wir mittels des Contentful Clients auch einzelne BlogPosts abholen, diese werden zum Beispiel auf der Blog Detailansicht benötigt. Genau wie im ersten Codebeispiel wird der ContentTyp blogPost abgefragt, zusätzlich wird das Feld fields.slug in dem Konfigurationsobjekt gesetzt. Mittels des Slugs wird ein BlogPost eindeutig identifiziert. Im Anschluss wird die bereits bekannte BlogPost Parser Funktion verwendet um die relevanten Daten des Artikels zu extrahieren:

client
  .getEntries({content_type: 'blogPost', 'fields.slug': slug})
  .then(result => console.log(parseBlogPost(result.items[0])))

Frontendimplementierung

Wir verwenden als Grundlage das Serverframework Express welches eine Reihe von Funktionen zur Verfügung stellt die mit dem Handling und der Verarbeitung von HTTP Anfragen in Verbindung stehen. Als Templatesprache verwenden wir DustJS welche mittels des Moduls consolidate in Express eingebunden wird.

Das folgende Codebeispiel stellt ein einfaches Grundgerüst für ein Expresssetup dar:

const express = require('express')
const consolidate = require('consolidate')

express()
    .set('view engine', 'dust')
    .set('views', __dirname + '/templates')
    .engine('dust', consolidate.dust)
    .use(express.static('static'))
    .get('/helloExpress', (req, res, next) => res.render('Hello'))
    .use((error, req, res, next) => res.render('Error', {message: error.message}))
    .listen(8888)

Im Detail haben die einzelnen Zeilen die folgende Bedeutung:

  • .set('view engine', 'dust')
    Es wird definiert auf welche Dateiendung die verwendeten Templates haben
  • .set('views', __dirname + '/templates')
    Der Ort der verwendeten Templates wird definiert
  • .engine('dust', consolidate.dust)
    Die Templateengine wird mittels "consolidate" Modul in das Framework geladen
  • .use(express.static('static'))
    Über die Static Middleware werden statische Resourcen aus dem Verzeichnis "static" ausgeliefert (zum Beispiel Stylesheet Dateien)
  • .get('/helloExpress', (req, res, next) => res.render('Hello'))
    Die Route "/helloExpress" wird registriert, beim Aufruf der URL wird das Template "Hello.dust" gerendert und an den Client ausgeliefert.
  • .use((error, req, res, next) => res.render('Error', {message: error.message}))
    Ein zusätzlicher Error Handler wird registriert um eine Fehlermeldung ausgeben zu können wenn zum Beispiel ein BlogPost nicht gefunden wurde.
  • .listen(8888)
    Der HTTP-Server wird an den Port 8888 gebunden, ab sofort können Requests an http://localhost:8888/helloExpress gesendet werden.

Wir werden nun das Express Grundgerüst um Routen erweitern die am Ende unseren Blog darstellen.

Blogüberblick

.get('/', (req, res, next) => {
  client
    .getEntries({content_type: 'blogPost'})
    .then(parseBlogPostList)
    .then(result => res.render('List', {entries: result.length > 0 ? result : null}))
    .catch(next)
})

Bei einem Aufruf der Route "/" wird der Contentful Client beauftragt alle Einträge vom Typ "BlogPost" abzurufen, das Ergebnis wird in den BlogPost Listparser geschickt und anschließend in das Template List gerendert und ausgegeben. Sollte es dabei zu Fehlern kommen wird der auftretende Fehler über den Promise Catchhandler an den Express Errorhandler weiter geleitet (s.o.).

Blogdetails

.get('/post/:slug', (req, res, next) => {
  client
    .getEntries({
      content_type: 'blogPost',
      'fields.slug': req.params.slug
    })
    .then(result => {
      if (result.total === 0) {
        return next(new Error(`blogpost "${slug}" not found`))
      }

      return parseBlogPost(result.items[0])
    })
    .then(result => res.render('Detail', {entry: result}))
    .catch(next)
})

Unter der Route /post/:slug wird die Detailansicht angezeigt, eine mögliche URL wäre z.B. http://localhost:8888/post/the-very-first-entry. Die spezielle Doppelpunktnotation gibt an dass :slug einen URL-Platzhalter darstellt. Dieser Wert (im Beispiel "the-very-first-entry") kann über req.params.slug ausgelesen werden, in unserem Beispiel verwenden wir den Slug um den Contentful Entry vom Typ "blogPost" mit dem Slug "the-very-first-entry" abzuholen. Es wird darauffolgend geprüft ob ein Ergebnis vorliegt und ggf in das Template "Detail" gerendert, falls nein wird über den Express ErrorHandler eine entsprechende Nachricht ausgegeben.

Beispielimplementierung

Die gesamte hier gezeigte Implementierung kann im Thomann Github Account unter https://github.com/thomn/contentful-blog-demo eingesehen werden, hier sind dann auch beispielhafte Templates zu finden mit denen der HTML Code aus den Contentful Daten erzeugt wird.
Vorraussetzung ist lediglich ein konfiguriertes Contentful Backend entsprechend der gezeigten Vorgehensweise in Blog Eintrag #1.

Mittels der folgenden Schritte kann die Demo Anwendung lokal ausgeführt werden:

Clonen des Repositories & wechseln in das erstellte Verzeichnis:

$ git clone git@github.com:thomn/contentful-blog-demo.git && cd contentful-blog-demo

Installieren der Dependencies:

$ npm install

In der Datei "server.js" müssen die korrekte SpaceID und der Access Token eingetragen werden:

Starten des Servers:

$ node server.js

Unter http://localhost:8888/ kann jetzt die Demo Anwendung abgerufen werden:

Verwandte Blogposts