Browse Source

Add proxy controller

Joachim M. Giæver 2 years ago
parent
commit
2b67ef5a7b

+ 142 - 0
src/Command/WfAddDatesCommand.php

@@ -0,0 +1,142 @@
+<?php
+
+namespace App\Command;
+
+use App\Entity\WebflowCollectionDates;
+use App\Entity\WebflowCollectionEvent;
+use App\Http\WebflowApi\WebflowApiCollection;
+use App\Http\WebflowApi\WebflowApiCollectionItem;
+use App\Http\WebflowApi\WebflowSites;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+
+class ExcausedException extends \Exception {}
+
+class WfAddDatesCommand extends Command
+{
+    protected static $defaultName = 'wf:add-dates';
+    public $site;
+
+    public function __construct(WebflowSites $webflowSites) {
+        $this->site = $webflowSites->getSiteByShortName('tromsotid');
+        parent::__construct();
+    }
+
+    protected function configure()
+    {
+        $this
+            ->setDescription('Add a short description for your command')
+            ->addArgument('arg1', InputArgument::OPTIONAL, 'Argument description')
+            ->addOption('option1', null, InputOption::VALUE_NONE, 'Option description')
+        ;
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $io = new SymfonyStyle($input, $output);
+
+        $fn = getcwd() . "/var/dates";
+
+        if (!file_exists($fn)) {
+            $io->success("File do not exist, but that all ok! :)");
+            return static::SUCCESS;
+        }
+
+        if (filesize($fn) <= 0) {
+            return static::SUCCESS;
+        }
+
+        $data = null;
+
+        try {
+            $dateApi = WebflowApiCollection::byId($this->site, WebflowCollectionDates::cid())->load(true);
+        } catch (\Exception $e) {
+            $io->error($e->getMessage());
+            return static::FAILURE;
+        }
+
+        $fh = fopen($fn, "r+");
+
+        fseek($fh, 0, SEEK_SET);
+        flock($fh, LOCK_EX);
+
+        if (filesize($fn) <= 0) {
+            flock($fh, LOCK_UN);
+            fclose($fh);
+            return static::SUCCESS;
+        }
+
+        $fc = fread($fh, filesize($fn));
+
+        $entries = array_filter(array_map(function (string $line) {
+           return json_decode($line, true); 
+        }, explode("\n", $fc)), function ($json) {
+            return !is_null($json);
+        });
+
+        foreach ($entries as $lineno => $line) {
+
+            try {
+                $event = WebflowApiCollectionItem::byId($this->site,
+                    $line['id'], [
+                        '_cid' => WebflowCollectionEvent::cid()
+                    ]
+                )->load(true);
+            } catch (NotFoundHttpException $e) {
+                $io->caution($e->getMessage());
+                unset($entries[$lineno]);
+                continue;
+            }
+
+            try {
+                foreach ($line['entries'] as $key => $entry) {
+                    $limit = ($dateApi->getLastResponseHeaders()['x-ratelimit-limit']?? [60])[0];
+                    $remaining = ($dateApi->getLastResponseHeaders()['x-ratelimit-remaining'] ?? [60])[0];
+
+                    $ratio = $remaining / $limit;
+
+                    if ($ratio < 0.3)
+                        throw new ExcausedException("Ratio met.");
+
+                    $item = $dateApi->createItem([
+                        'name' => $entry['title'],
+                        'dato' => $entry['date'],
+                        'varighet' => $entry['duration'],
+                        'arrangement' => [$line['id']],
+                        '_draft' => true,
+                        '_archived' => false
+                    ]);
+                    unset($line['entries'][$key]);
+                }
+            } catch (\Exception $e) {
+                $io->caution($e->getMessage());
+            } finally {
+                $entries[$lineno] = $line;
+            }
+        }
+
+        foreach ($entries as $key => $entry)
+            if (sizeof($entry['entries']) == 0)
+                unset($entries[$key]);
+
+        fseek($fh, 0, SEEK_SET);
+        ftruncate($fh, 0);
+
+        if (sizeof($entries) != 0) {
+            fwrite($fh, implode("\n", array_map(function ($json) {
+                return json_encode($json);
+            }, $entries)));
+        }
+
+        flock($fh, LOCK_UN);
+        fclose($fh);
+
+        return static::SUCCESS;
+    }
+
+}

+ 118 - 0
src/Controller/ProxyController.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Controller;
+
+use PhpParser\Node\Stmt\Throw_;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\Cache\Adapter\FilesystemAdapter;
+use Symfony\Component\HttpClient\Exception\TimeoutException;
+use Symfony\Component\HttpClient\HttpClient;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\Routing\Annotation\Route;
+use Symfony\Contracts\Cache\CacheInterface;
+use Symfony\Contracts\Cache\ItemInterface;
+
+class ProxyController extends AbstractController
+{
+
+    private $cache;
+    private $client; 
+    private $expiration = 3600 * 2;
+    private $tryAfter = 1800 * 2;
+
+    public function __construct(CacheInterface $cache)
+    {
+        $this->cache = $cache;
+        $this->client = HttpClient::create();
+    }
+
+    /**
+     * @Route("/proxy/{type}/{name}", name="proxy")
+     */
+    public function proxy(string $type, string $name): Response {
+
+        $valid = [
+            'kategorier',
+            'malgruppe',
+            'program',
+        ];
+
+        if (!in_array($type, $valid))
+            throw new NotFoundHttpException(sprintf("Invalid type «%s»", $type));
+
+        $key = sha1(sprintf('%s/%s', $type, $name));
+
+        $item = $this->cache->getItem($key);
+
+        if ($type == 'program') 
+            $callback = function () use ($name) {
+                return $this->client->request(
+                    'GET',
+                    sprintf('https://tromsotid.no/program%s',
+                        base64_decode($name . '==')
+                    ), [
+                        'max_duration' => 3
+                    ]
+                );
+            };
+        else 
+            $callback = function () use ($type, $name) {
+                return $this->client->request(
+                    'GET', 
+                    sprintf('https://tromsotid.no/%s/%s', $type, $name), [
+                        'max_duration' => 3
+                    ]
+                );
+            };
+
+        if ($item->isHit()) {
+            $data = $item->get();
+            if ((time() - $data['last_update']) > $this->tryAfter) {
+                $this->updateCache($item, $callback);
+            }
+        } else {
+            $this->updateCache($item, $callback);
+        }
+
+        if (!isset($item->get()['data']))
+            throw new TimeoutException("TimeoutException");
+
+        /*
+        if ($type == 'program') {
+            dump($type, $name, base64_decode($name . '=='));
+            return new Response(htmlentities($item->get()['data']));
+        }
+         */
+
+        return new Response(($item->get()['data']));
+
+    }
+
+    private function updateCache(ItemInterface $item, $callback, int $retry = 5): ?ItemInterface {
+
+        try { 
+            $resp = $callback();
+
+            if ($resp->getStatusCode() != 200)
+                throw new \Exception("Failed with status code " + $resp->getStatusCode());
+                
+            $item->set([
+                'last_update' => time(),
+                'data' => $resp->getContent()
+            ]);
+
+        } catch (\Exception $e) {
+            if ($retry > 0) {
+                return $this->updateCache($item, $callback, $retry - 1);
+            }
+        }
+
+        if (isset($item->get()['data'])) {
+            $item->expiresAfter($this->expiration);
+            $this->cache->save($item);
+        }
+
+        return $item;
+    }
+}

+ 107 - 0
src/DataPersister/WebflowDataPersister.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\DataPersister;
+
+use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
+use App\Entity\WebflowCollectionEvent;
+use App\Http\WebflowApi\WebflowApiCollection;
+use App\Http\WebflowApi\WebflowSites;
+use App\Serializer\ItemSerializer;
+use Doctrine\Common\Annotations\Reader;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\HttpClient\HttpClient;
+use Symfony\Component\Security\Core\Exception\InsufficientAuthenticationException;
+
+final class WebflowDataPersister implements ContextAwareDataPersisterInterface{
+
+    private $logger;
+    private $site;
+
+    private $reader;
+
+    public function __construct(LoggerInterface $loggerInterface, WebflowSites $webflowSites, Reader $reader)
+    {
+        $this->logger = $loggerInterface;
+        $this->logger->debug(__METHOD__, $webflowSites->getSites());
+        $this->site = $webflowSites->getSiteByShortName('tromsotid');
+        $this->reader = $reader;
+    }
+
+    public function supports($data, array $context = []): bool
+    {
+        $this->logger->debug(__METHOD__, [
+            'data' => $data,
+        ] + $context);
+
+        return $data instanceof WebflowCollectionEvent;
+        
+    }
+
+    public function persist($data, array $context = [])
+    {
+        $val = [
+            'id' => $data->getId(),
+            'cid' => $data->getCid(),
+            'name' => $data->getTitle(),
+            'place' => $data->getPlace(),
+        ];
+
+        $client = HttpClient::create();
+
+        $resp = $client->request('POST', "https://www.google.com/recaptcha/api/siteverify", [
+            'body' => [
+                'secret' => '6LfRxgAVAAAAAJBFO492ap_OUfQz3kJotl2Xad_V',
+                'response' => $data->getCaptcha(),
+                'remoteip' => $_SERVER['REMOTE_ADDR'],
+            ]
+        ]);
+
+        $content = $resp->toArray();
+
+        if (!$content['success'])
+            throw new InsufficientAuthenticationException(sprintf("Error-codes\nAuthentication: %s (captcha)", join(", ", $content['error-codes'])));
+
+        $dates = array_map(function ($data) {
+            $data['date'] = new \DateTime($data['date']);
+            return $data;
+        }, $data->getHappensOn());
+
+        $itemSerializer = new ItemSerializer($this->reader, $data);
+        
+        $data->setHappensOn([]);
+
+        $item = WebflowApiCollection::byId($this->site, $data->getCid())->createItem(
+            $itemSerializer->restoreProperties()
+        );
+
+        $data = WebflowCollectionEvent::fromClient($item, $this->reader);
+
+        $fh = fopen(getcwd() . '/../var/dates', "a+");
+        flock($fh, LOCK_EX);
+
+        $entries = [];
+
+        foreach ($dates as $date) {
+            $entries[] = [
+                'date' => $date['date']->format(\DateTime::RFC3339_EXTENDED),
+                'duration' => $date['duration'],
+                'title' => sprintf("%s, %s -> %dh", $data->getTitle(), $date['date']->format('d-m-Y H:i'), $date['duration']),
+            ];
+        }
+
+        fwrite($fh, json_encode([
+            'id' => $data->getId(),
+            'entries' => $entries,
+        ]) . "\n");
+
+        flock($fh, LOCK_UN);
+        fclose($fh);
+
+        return $data;
+    }
+
+    public function remove($data, array $context = [])
+    {
+        
+    }
+}

+ 43 - 0
src/Entity/AbstractWebflowCollectionItem.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Entity;
+
+use App\Serializer\ItemSerializedName;
+
+
+abstract class AbstractWebflowCollectionItem extends AbstractWebflowEntity {
+
+    /**
+     * @ItemSerializedName("_archived")
+     */
+    private $archived = false;
+
+    /**
+     * @ItemSerializedName("_draft")
+     */
+    private $draft = true;
+
+    protected function setArchived(bool $a): self {
+        $this->archived = $a;
+        return $this;
+    }
+
+    public function getArchived(): ?bool {
+        return $this->archived;
+    }
+
+    protected function setDraft(bool $a): self {
+        $this->draft = $a;
+        return $this;
+    }
+
+    public function getDraft(): ?bool {
+        return $this->draft;
+    }
+
+    public function getCid(): string {
+        return static::$cid;
+    }
+
+    abstract public static function cid(): string;
+}

+ 67 - 0
src/Entity/AbstractWebflowEntity.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Entity;
+
+use App\Http\WebflowApi\AbstractWebflowApiClient;
+use App\Serializer\ItemSerializer;
+use Doctrine\Common\Annotations\Reader;
+
+abstract class AbstractWebflowEntity {
+
+    private $webflowApiClient;
+
+    private $fieldTranslation = [];
+
+    private $leftOvers = [];
+
+    public function __construct(?string $s = null) {
+        $s;
+    }
+
+    public static function fromClient(AbstractWebflowApiClient $webflowApiClient, Reader $reader) : self {
+        return (new static())->setClient($webflowApiClient)->addData($reader);
+    }
+
+    private function setClient(AbstractWebflowApiClient $webflowApiClient): self {
+        $this->webflowApiClient = $webflowApiClient;
+        return $this;
+    }
+
+    public function getId(): ?string {
+        return $this->id;
+    }
+
+    protected function setId(string $s): self {
+        $this->id = $s;
+        return $this;
+    }
+
+    protected function getWebflowApiClient(): AbstractWebflowApiClient {
+        return $this->webflowApiClient;
+    }
+
+    protected function getFieldMapping(): array {
+        return $this->fieldTranslation;
+    }
+
+    private function addData(Reader $reader): self {
+        $itemSerializer = new ItemSerializer($reader, $this);
+        foreach($this->webflowApiClient->data as $field => $data) {
+            if ($data != null && $fn = $itemSerializer->setFn($field))
+                $fn($data);
+            else
+                $this->leftOvers[$field] = $data;
+        }
+
+        return $this;
+    }
+
+    public function getLeftOvers(): array {
+        return $this->leftOvers;
+    }
+
+    public function getFields(): array {
+        return [];
+    }
+
+}

+ 76 - 0
src/Entity/WebflowCollectionAudience.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace App\Entity;
+
+use ApiPlatform\Core\Annotation\ApiProperty;
+use ApiPlatform\Core\Annotation\ApiResource;
+use App\Http\WebflowApi\AbstractWebflowApiClient;
+use App\Serializer\ItemSerializedName;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * @ApiResource(
+ *  itemOperations={"get"},
+ *  collectionOperations={"get"}
+ * )
+ */
+class WebflowCollectionAudience extends AbstractWebflowCollectionItem {
+
+    /**
+     * @ApiProperty(identifier=true)
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("_id")
+     */
+    protected $id;
+
+    /*
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("_cid")
+     */
+    protected static $cid = '5ed10cffd2b21cfb3b269088';
+
+    /*
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     */
+    private $name;
+
+    /*
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     */
+    private $slug;
+
+    public static function cid(): string {
+        return self::$cid;
+    }
+
+    public function getName(): ?string {
+        return $this->name;
+    }
+
+    public function setName(string $name): self {
+        $this->name = $name;
+        return $this;
+    }
+
+    public function getSlug(): ?string {
+        return $this->slug;
+    }
+
+    protected function setSlug(string $slug): self {
+        $this->slug = $slug;
+        return $this;
+    }
+
+}

+ 73 - 0
src/Entity/WebflowCollectionCategory.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace App\Entity;
+
+use ApiPlatform\Core\Annotation\ApiProperty;
+use ApiPlatform\Core\Annotation\ApiResource;
+use App\Serializer\ItemSerializedName;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * @ApiResource(
+ *  itemOperations={"get"},
+ *  collectionOperations={"get"}
+ * )
+ */
+class WebflowCollectionCategory extends AbstractWebflowCollectionItem {
+    /**
+     * @ApiProperty(identifier=true)
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("_id")
+     */
+    protected $id;
+
+    /*
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     */
+    protected static $cid = '5ed0f77a42f75848a95ebf03';
+
+    /*
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     */
+    private $name;
+
+    /*
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     */
+    private $slug;
+
+    public static function cid(): string {
+        return self::$cid;
+    }
+
+    public function getName(): ?string {
+        return $this->name;
+    }
+
+    public function setName(string $name): self {
+        $this->name = $name;
+        return $this;
+    }
+
+    public function getSlug(): ?string {
+        return $this->slug;
+    }
+
+    protected function setSlug(string $slug): self {
+        $this->slug = $slug;
+        return $this;
+    }
+
+}

+ 76 - 0
src/Entity/WebflowCollectionDates.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace App\Entity;
+
+use ApiPlatform\Core\Annotation\ApiProperty;
+use ApiPlatform\Core\Annotation\ApiResource;
+use App\Http\WebflowApi\AbstractWebflowApiClient;
+use App\Serializer\ItemSerializedName;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * @ApiResource(
+ *  itemOperations={"get"},
+ *  collectionOperations={"get"}
+ * )
+ */
+class WebflowCollectionDates extends AbstractWebflowCollectionItem {
+
+    /**
+     * @ApiProperty(identifier=true)
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("_id")
+     */
+    protected $id;
+
+    /*
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("_cid")
+     */
+    protected static $cid = '5ed6105a05af1d7d085a9002';
+
+    /*
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     */
+    private $name;
+
+    /*
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     */
+    private $slug;
+
+    public static function cid(): string {
+        return self::$cid;
+    }
+
+    public function getName(): ?string {
+        return $this->name;
+    }
+
+    public function setName(string $name): self {
+        $this->name = $name;
+        return $this;
+    }
+
+    public function getSlug(): ?string {
+        return $this->slug;
+    }
+
+    protected function setSlug(string $slug): self {
+        $this->slug = $slug;
+        return $this;
+    }
+
+}

+ 473 - 0
src/Entity/WebflowCollectionEvent.php

@@ -0,0 +1,473 @@
+<?php
+
+namespace App\Entity;
+
+use ApiPlatform\Core\Annotation\ApiProperty;
+use ApiPlatform\Core\Annotation\ApiResource;
+use Symfony\Component\Serializer\Annotation\Groups;
+use App\Serializer\ItemSerializedName;
+use Symfony\Component\Validator\Constraints as Assert;
+use Symfony\Component\Validator\Context\ExecutionContextInterface;
+
+/**
+ * @ApiResource(
+ *  itemOperations={"get"},
+ *  collectionOperations={"get" = {
+ *      "normalization_context" = {
+ *            "groups" = {"event:collection:read"},
+ *      },
+ *  }, "post" = {
+ *      "denormalization_context" = {
+ *          "groups" = {"event:collection:write"},
+ *      },
+ *  }}
+ * )
+ */
+
+class WebflowCollectionEvent extends AbstractWebflowCollectionItem {
+
+    /**
+     * @ApiProperty(identifier=true)
+     * @Groups({
+     *  "event:collection:read",
+     * })
+     * @ItemSerializedName("_id")
+     */
+    protected $id;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     * })
+     * @ItemSerializedName("_cid")
+     */
+    protected static $cid = '5ebbafce7b593028a5f8f29a';
+
+    /**
+     * @Groups({
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("@ignore")
+     * @Assert\NotNull
+     */
+    private $captcha;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write"
+     * })
+     * @ItemSerializedName("name")
+     * @Assert\NotNull
+     */
+    private $title;
+    /*
+     * @Groups({
+     *  "event:collection:read",
+     * })
+     */
+    private $slug;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("kjop-billett-link")
+     * @Assert\Url(
+     *  message = "Url '{{ value }}' er ugyldig. Husk begynne med http://"
+     * )
+     * @Assert\NotNull
+     */
+    private $bookingUrl;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("link-til-nettside")
+     * @Assert\Url(
+     *  message = "Url '{{ value }}' er ugyldig. Husk begynne med http://"
+     * )
+     * @Assert\NotNull
+     */
+    private $websiteUrl;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("kapasitet-tekst")
+     * @Assert\NotNull(
+     *  message= "Vennligst oppgi kapasitet"
+     * )
+     */
+    private $capacity;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("sted-2")
+     * @Assert\NotNull(
+     *  message= "Vennligst oppgi lokalisjon for eventet"
+     * )
+     */
+    private $place;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("organisasjonsnr")
+     * @Assert\Length(
+     *  min = 9,
+     *  max = 9,
+     *  allowEmptyString = false,
+     * )
+     * @Assert\NotNull
+     */
+    private $organizationNumber;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("arrangor")
+     * @Assert\NotNull(
+     *  message= "Vennligst oppgi arrangør"
+     * )
+     */
+    private $organizer;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("kontaktperson")
+     * @Assert\NotNull(
+     *  message= "Vennligst angi kontaktperson"
+     * )
+     */
+    private $contactPerson;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("e-post")
+     * @Assert\Email(
+     *  message = "Epostadresse '{{ value }}' er ugyldig",
+     * )
+     * @Assert\NotNull
+     */
+    private $contactEmail;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("telefonnummer")
+     * @Assert\NotNull(
+     *  message= "Vennligst angi en kontakt-telefon"
+     * )
+     */
+    private $contactPhone;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @Assert\NotNull
+     */
+    private $description;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @Assert\Url(
+     *  message = "Url '{{ value }}' er ugyldig. Husk begynne med http://"
+     * )
+     * @Assert\NotNull
+     */
+    private $image;
+
+    /**
+     * @ItemSerializedName("bilde")
+     */
+    private $bilde;
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("opptrer")
+     * @Assert\Count(
+     *  min = 1,
+     *  minMessage = "Eventet må opptre minst {{ limit }} ganger"
+     * )
+     * @Assert\NotNull
+     */
+    private $happensOn = [];
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("kategorier")
+     * @Assert\Count(
+     *  min = 1,
+     *  minMessage = "Eventet må ha minst {{ limit }} kategori"
+     * )
+     * @Assert\NotNull
+     */
+    private $categories = [];
+
+    /**
+     * @Groups({
+     *  "event:collection:read",
+     *  "event:collection:write",
+     * })
+     * @ItemSerializedName("malgruppe-r")
+     * @Assert\Count(
+     *  min = 1,
+     *  minMessage = "Eventet må ha minst {{ limit }} målgruppe"
+     * )
+     * @Assert\NotNull
+     */
+    private $audiences = [];
+
+    private $featured = false;
+
+    public static function cid(): string {
+        return self::$cid;
+    }
+
+    public function setCaptcha(string $c): self {
+        $this->captcha = $c;
+        return $this;
+    }
+
+    public function getCaptcha(): ?string {
+        return $this->captcha;
+    }
+   
+    public function getTitle(): ?string {
+        return $this->title;
+    }
+
+    public function setTitle(string $n): self {
+        $this->title = $n;
+        return $this;
+    }
+
+    public function getSlug(): ?string {
+        return $this->slug;
+    }
+
+    protected function setSlug(string $s): self {
+        $this->slug = $s;
+        return $this;
+    }
+
+    public function getContactEmail(): ?string {
+        return $this->contactEmail;
+    }
+
+    public function setContactEmail(string $e): self {
+        $this->contactEmail = $e;
+        return $this;
+    }
+
+    public function getContactPhone(): ?string {
+        return $this->contactPhone;
+    }
+
+    public function setContactPhone(string $s): self {
+        $this->contactPhone = $s;
+        return $this;
+    }
+
+    public function getBookingUrl(): ?string {
+        return $this->bookingUrl;
+    }
+
+    public function setBookingUrl($s): self {
+        $this->bookingUrl = $s;
+        return $this;
+    }
+
+    public function getWebsiteUrl(): ?string {
+        return $this->websiteUrl;
+    }
+
+    public function setWebsiteUrl(string $s): self {
+        $this->websiteUrl = $s;
+        return $this;
+    }
+
+    public function getCapacity(): ?string {
+        return $this->capacity;
+    }
+
+    public function setCapacity(string $i): self {
+        $this->capacity= $i;
+        return $this;
+    }
+
+    public function getPlace(): ?string {
+        return $this->place;
+    }
+
+    public function setPlace(string $s): self {
+        $this->place = $s;
+        return $this;
+    }
+
+    public function getContactPerson(): ?string {
+        return $this->contactPerson;
+    }
+
+    public function setContactPerson(string $s): self {
+        $this->contactPerson = $s;
+        return $this;
+    }
+
+    public function getOrganizationNumber(): ?string {
+        return $this->organizationNumber;
+    }
+
+    public function setOrganizationNumber(string $s): self {
+        $this->organizationNumber = $s;
+        return $this;
+    }
+
+    public function getOrganizer(): ?string {
+        return $this->organizer;
+    }
+
+    public function setOrganizer(string $s): self {
+        $this->organizer = $s;
+        return $this;
+    }
+
+    public function getDescription(): ?string {
+        return $this->description;
+    }
+
+    public function setDescription(string $s): self {
+        $this->description = sprintf('<p>%s</p>', preg_replace('/[\\n]+/', '</p><p>', strip_tags($s)));
+        return $this;
+    }
+
+    public function getImage() {
+        return $this->image;
+    }
+
+    public function setImage($a): self {
+        $this->image = $a;
+
+        if (is_string($this->image))
+            $this->bilde;
+
+        return $this;
+    }
+
+    public function getBilde() {
+        return $this->image;
+    }
+
+    public function getCategories(): ?array {
+        $this->categories = array_map(function ($category) {
+            return $category;
+        }, $this->categories); 
+        return $this->categories;
+    }
+
+    public function setCategories(array $a): self {
+        return $this->setKategorier($a);
+    }
+
+    protected function setKategorier(array $a): self {
+        $this->categories = $a;
+        return $this;
+    }
+
+    public function getAudiences(): ?array {
+        $this->audiences = array_map(function ($audience) {
+            return $audience;
+        }, $this->audiences);
+
+        return $this->audiences;
+    }
+
+    public function setAudiences(array $a): self {
+        $this->audiences = $a;
+        return $this;
+    }
+
+    public function getHappensOn(): ?array {
+        return $this->happensOn;
+    }
+
+    public function setHappensOn(array $a): self {
+        $this->happensOn = $a;
+        return $this;
+    }
+
+    public function getFeatured(): ?bool {
+        return $this->featured;
+    }
+
+    protected function setFeatured(bool $f): self {
+        $this->featured = $f;
+        return $this;
+    }
+
+    /**
+     * @Assert\Callback
+     */
+
+    public function validate(ExecutionContextInterface $context, $payload) {
+    
+        $happens = $this->getHappensOn();
+
+        $violation = false;
+
+        foreach ($happens as $key => $value) {
+            $duration = floatval($value['duration'] ?? 0.0);
+            if ($duration < 0.25 || $duration > 24.0) {
+                $context->buildViolation("Varighet må være angitt og mellom 0.25 og 24")->addViolation();
+                $violation = true;
+            }
+
+            $date = new \DateTime($value['date'] ?? "2005-05-05 05:05:05");
+            $fStart = new \DateTime('2020-06-25 00:00:01');
+            $fEnd = new \DateTime('2020-08-09 23:59:00');
+
+            if ($date < $fStart || $date > $fEnd) {
+                $context->buildViolation("Eventet må skje mellom 25/6 - 09/8, år 2020.")->addViolation();
+                $violation = true;
+            }
+
+            if ($violation)
+                break;
+
+        }
+
+    }
+
+}

+ 24 - 0
src/Serializer/ItemSerializedName.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Serializer;
+
+use Doctrine\Common\Annotations\Annotation;
+
+/**
+ * @Annotation
+ */
+class ItemSerializedName {
+
+    private $origName;
+
+    public function __construct(array $data) {
+        $this->origName = $data['value'] ?? null;
+
+        if (empty($this->origName))
+            throw new \InvalidArgumentException(sprintf("Value must me non-empty for %s", static::class));
+    }
+
+    public function getFieldName(): string {
+        return $this->origName;
+    }
+}

+ 83 - 0
src/Serializer/ItemSerializer.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Serializer;
+
+use App\Entity\AbstractWebflowEntity;
+use Closure;
+use Doctrine\Common\Annotations\Reader;
+use ReflectionClass;
+
+class ItemSerializer {
+
+    private $class;
+    private $map;
+    private $reader;
+    private $methods;
+
+    public function __construct(Reader $reader, AbstractWebflowEntity $class) {
+        $this->reader = $reader;
+        $this->class = $class; 
+        $this->refClass = new ReflectionClass(get_class($class));
+
+        foreach ($this->refClass->getMethods() as $key => $method)
+            $this->methods[strtolower($method->getName())] = $method;
+
+
+        foreach ($this->refClass->getParentClass() as $class)
+            $this->mapProperties(new ReflectionClass($class));
+
+        $this->mapProperties($this->refClass);
+    }
+
+    private function mapProperties(ReflectionClass $c): void {
+        foreach ($c->getProperties() as $prop) {
+            $c = $this->reader->getPropertyAnnotation($prop, ItemSerializedName::class);
+            
+            if ($c && $c->getFieldName() == '@ignore')
+                continue;
+
+            $this->map[$prop->getName()] = $c ? $c->getFieldName() : $prop->getName();
+        }
+    }
+
+    public function restoreProperties(): array {
+        $d = [];
+
+        foreach ($this->map as $field => $origField)
+            $d[$origField] = ($fn = $this->getFn($field)) ? $fn() : null;
+
+        return $d;
+    }
+
+    public function setFn(string $name): ?array {
+        $m = array_filter($this->map, function(string $val) use ($name) {
+            return $val == $name;
+        });
+
+        if (sizeof($m) == 0)
+            return null;
+
+        $fn = sprintf("set%s", strtolower(current(array_flip($m))));
+
+        if (!isset($this->methods[$fn]))
+            return null;
+
+        return ([$this->class, $this->methods[$fn]->getName()]);
+    }
+
+    public function getFn(string $name): ?array {
+        $m = array_filter($this->map, function (string $key) use ($name) {
+            return $key == $name;
+        }, ARRAY_FILTER_USE_KEY);
+
+        if (sizeof($m) == 0)
+            return null;
+
+        $fn = sprintf("get%s", strtolower(current(array_flip($m))));
+
+        if (!isset($this->methods[$fn]))
+            return null;
+
+        return [$this->class, $this->methods[$fn]->getName()];
+    }
+}