Content from API, language, parsing and caching

There is this great cookbook example for creating “pages” based on content from an API. That’s precisely what I use:

Now, I changed it a bit as I need to parse XML and need to parse multiple languages (code bellow).

Two things I can’t figure out:

  • The cookbook examples only show simple types, like strings, numbers or simple arrays (saved as a coma separated string). But how do I store deeply nested structures? When I just assign an array as a content field, it assumes it’s a string and won’t output anything. See createVirtualJobPage function, the line where I assign `$job[‘jobDescriptions’]. Thats’ an array. How do I have to stringify or parse my array so it can be saved as page data and how do I convert it back to an array later in the template?
  • How do I make my language switcher work with this? Currently, it correctly takes the German slug of the parent and the slug of the child, but then leads to a 404. Also in the panel, when I try to switch to the German version of the page (English being the main language) it tells, me it couldn’t find the page.
  • How do I best cache something like this, so the API request isn’t done on every page request and parent page request?
<?php

use Kirby\Cms\Page;
use Kirby\Cms\Pages;
use Kirby\Http\Remote;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\Xml;
use Kirby\Toolkit\A;
use Kirby\Cms\App;

class CareersPage extends Page
{
    private static $personio_base_url = '<the-personio-api-url-of-the-project>';

    static function createVirtualJobPage(array $job, string $languageCode)
    {
        return [
            'title' => $job['name'],
            'job_personio_id' => $job['id'],
            'job_office' => $job['office'],
            'job_department' => $job['department'],
            'job_recruiting_category' => $job['recruitingCategory'],
            'job_employment_type' => $job['employmentType'],
            'job_seniority' => $job['seniority'],
            'job_schedule' => Str::replace($job['schedule'], '-', '_'),
            'job_experience' => $job['yearsOfExperience'],
            'job_keywords' => $job['keywords'],
            'job_created' => $job['createdAt'],
            'job_body' => $job['jobDescriptions'],
            'job_description_language' => $languageCode,
            'job_apply_url' =>
                'https://' .
                CareersPage::$personio_base_url .
                '/job/' .
                $job['id'] .
                '?display=' .
                $languageCode .
                '#apply',
        ];
    }

    public function children()
    {
        $kirby = $this->kirby();
        $languageCodes = $kirby->languages()->codes();
        $results = [];
        $pages = [];

        foreach ($languageCodes as $languageCode) {
            $request = Remote::get(
                $this::$personio_base_url . '/xml?language=' . $languageCode,
            );

            if ($request->code() === 200) {
                $content = Xml::parse($request->content());
                $results[$languageCode] = $content;
            }
        }

        if (count($results) > 0) {
            foreach (
                $results[$kirby->defaultLanguage()->code()]['position']
                as $job
            ) {
                $translations = [];
                foreach ($languageCodes as $languageCode) {
                    $translatedJob = A::filter(
                        $results[$languageCode]['position'],
                        fn($item) => $item['id'] === $job['id'],
                    );

                    if (count($translatedJob) >= 1) {
                        $translatedJob = A::first($translatedJob);

                        $translations[$languageCode] = [
                            'code' => $languageCode,
                            'slug' => Str::slug($translatedJob['name']),
                            'content' => $this->createVirtualJobPage(
                                $translatedJob,
                                $languageCode,
                            ),
                        ];
                    }
                }

                $pages[] = [
                    'slug' => Str::slug($job['name']),
                    'num' => $job['id'],
                    'template' => 'job-offer',
                    'model' => 'job-offer',
                    'translations' => $translations,
                ];
            }
        }

        return Pages::factory($pages, $this);
    }
}

I have a boilerplate plugin to make it easier to work with multilingual content coming from an API: GitHub - bvdputte/kirby-vpkit: Virtual pages helper for multilingual Kirby 3

It should answer your last 2 topics.

Considering the “complex field” → if you use the content from the API as virtual pages, you could basically serialize whatever you want inside a field however you like (arrays ?) and unserialize when needed (e.g. in a template).

Foremost, thanks for your reply.

Do you have a concrete example how this is done best? I tried something like this:

'job_body' => JSON::encode($job['jobDescriptions']),

But I feel like I still can’t load the content in the template. For me, it seems that the translations object can’t handle complex, nested data. Because when I put the content not in translations but directly into content of the virtual page itself, I can render the data (but of course only in one language).

Okay, finally, hours later, I found a way to make it work. I’m gonna answer my questions directly:

  1. The cookbook examples only show simple types, like strings, numbers or simple arrays (saved as a coma separated string). But how do I store deeply nested structures? When I just assign an array as a content field, it assumes it’s a string and won’t output anything. See createVirtualJobPage function, the line where I assign `$job[‘jobDescriptions’]. Thats’ an array. How do I have to stringify or parse my array so it can be saved as page data and how do I convert it back to an array later in the template?

JSON encode the array with Kirby\Data\Json helper:

'job_body' => Json::encode($job['jobDescriptions'])

And in the template, decode it again

In Twig:

{% for item in page.job_body.toData('json') %}

In PHP:

<?php foreach ($page->job_body()->toData('json') as $item): ?>
  1. How do I make my language switcher work with this? Currently, it correctly takes the German slug of the parent and the slug of the child, but then leads to a 404. Also in the panel, when I try to switch to the German version of the page (English being the main language) it tells, me it couldn’t find the page.

Ensure the slug is set in all translations and the main object.

  1. How do I best cache something like this, so the API request isn’t done on every page request and parent page request?

See Caching | Kirby CMS, section “Set your own cache” and right after “Using the cache”. That works pretty well, for example, when set to 30 minutes. And it can be done in the children() function directly.

Bonus:

The Virtual pages helper kit | Kirby CMS might help you with all these steps if you are using virtual pages with static parent pages. It didn’t work for me, as the functionality I use is tied to a template.

Sorry for the late reply, but glad you figured it out :metal:

Out of curiousity; where/how did you setup the children() function then?

In the page model itself, as in Content from an API | Kirby CMS.

I saw you basically do the same with the plugin (kirby-vpkit/virtual-page.php at master · bvdputte/kirby-vpkit · GitHub) but the core of your plugin only works with a parent statically set in the config, at least that is what I understood.

In my case, I wanted this to be completely flexible in the sense that content authors can simply create a page from a template and then have this functionality available, no matter with which slugs or nested structures they work.

You’re right.

I don’t remember anymore why I made this decision :thinking:

Maybe I’ll have to reconsider this in my plugin then, so it can be of better use for ppl?

Sure. Your plugin is wonderful. I learned a lot just from looking at the source. Especially the revalidating of the cache in case the external endpoint isn’t available is genius.

If you put a solution in place to automatically parse nested content in the translations too, I believe it would be my the-one-to-go solution for working with external API data.

I was so close to be finished with my implementation that it was a bit of a stretch for me to switch everything to the plugin, but I think I’ll consider using it in the future.

1 Like

In your setup; what happens when a content author creates multiple pages with the same template. This would result in having multiple pages with the exact same children?

I believe this is something you’ld explicitely not want?

That’s possible, yes. It’s not a case I really need at this moment, but they have the possibility to do that. I often leave that decision up to my clients how the assemble their websites. As I currently use a static cache ID those would all use the same cache and at least performance wise, it wouldn’t be a problem. But it’s entirely possible that, for some reason you need a different landing page with the data from the API again, but with different content (as you still can add content blocks next to the API data).

There sure is a case to have this data on multiple pages. Maybe not the way I currently use it. But as I get job offer data from an external platform, I can totally see a case where this can be dynamically added via a custom content block in the panel, to any site or blog post, whenever it’s the right context to advertise the open positions.

I also use this setup to display jobs from another platform in a Kirby website.

I chose to hook up all the jobs under /jobs as virtual pages with my plugin. Whenever they needed to reference jobs, you can use existing fields (e.g. the pages field) in your blueprints (or you could use pages filters in your templates).

This way, I avoid duplicate content issues etc.

But, I don’t know your setup :slight_smile: . Hence, I think I’ll leave as is in my plugin.
Cheers!