From ca6cc618a7b0048bf3d2678c6c527fc16b731172 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Wed, 9 Sep 2026 11:39:25 +0200 Subject: [PATCH] Refactored source definition --- CHANGELOG.md | 2 + README.md | 46 ++++++++++-- phpstan.dist.neon | 2 + src/Command/SourceListCommand.php | 28 +++++++ src/Command/SourceReadCommand.php | 26 +++++++ src/Command/SourceShowCommand.php | 25 +++++++ src/Import/DataSourceImporter.php | 2 +- src/Source/AbstractSource.php | 73 +++++++++++++++++++ src/Source/MtmSpatialMaps/HandicapParking.php | 32 ++++++-- src/Source/SourceInterface.php | 67 ++++++++++++++++- src/SourceManager.php | 61 ++++++++++++++++ .../SourceArgumentValueResolver.php | 34 +++++++++ tests/Command/ImportCommandTest.php | 2 +- tests/Import/DataSourceImporterTest.php | 2 +- 14 files changed, 381 insertions(+), 21 deletions(-) create mode 100644 src/Command/SourceListCommand.php create mode 100644 src/Command/SourceReadCommand.php create mode 100644 src/Command/SourceShowCommand.php create mode 100644 src/Source/AbstractSource.php create mode 100644 src/SourceManager.php create mode 100644 src/ValueResolver/SourceArgumentValueResolver.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e6e1c78..c67b120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-7](https://github.com/itk-dev/enter/pull/7) + Refactored source definition * [#3](https://github.com/itk-dev/enter/pull/3) * Import command that reads a geospatial feed, reprojects it to WGS84 and upserts it to an NGSI-LD broker. * A committed record per data set — feed, CRS, model, DCAT-AP metadata. diff --git a/README.md b/README.md index c80ab34..4a3f4ad 100644 --- a/README.md +++ b/README.md @@ -32,15 +32,47 @@ task import -- mtm_spatialmaps-handicap-parking # import task import -- mtm_spatialmaps-handicap-parking --dry-run --limit 5 # print the payload instead ``` -### Source manifest +### Sources + +Adding a data source means adding a [`SourceInterface`](src/Source/SourceInterface.php) implementation. The easiest way +to do this is by extending [`AbstractSource`](src/Source/AbstractSource.php), e.g.: + +```php +getSources(); + + $io->writeln(sprintf('#sources: %d', \count($sources))); + foreach ($sources as $source) { + $io->writeln((string) $source); + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/SourceReadCommand.php b/src/Command/SourceReadCommand.php new file mode 100644 index 0000000..cbbaba6 --- /dev/null +++ b/src/Command/SourceReadCommand.php @@ -0,0 +1,26 @@ +getReader($source); + // $reader->read($source); + // … + } +} diff --git a/src/Command/SourceShowCommand.php b/src/Command/SourceShowCommand.php new file mode 100644 index 0000000..930b974 --- /dev/null +++ b/src/Command/SourceShowCommand.php @@ -0,0 +1,25 @@ +writeln(Yaml::dump($source->toArray(), PHP_INT_MAX)); + + return Command::SUCCESS; + } +} diff --git a/src/Import/DataSourceImporter.php b/src/Import/DataSourceImporter.php index f2a7fa7..597646b 100644 --- a/src/Import/DataSourceImporter.php +++ b/src/Import/DataSourceImporter.php @@ -113,7 +113,7 @@ private function registry(): array $registry = []; foreach ($this->sources as $source) { - $registry[$source->key()] = $source; + $registry[$source->id] = $source; } return $registry; diff --git a/src/Source/AbstractSource.php b/src/Source/AbstractSource.php new file mode 100644 index 0000000..4f655ed --- /dev/null +++ b/src/Source/AbstractSource.php @@ -0,0 +1,73 @@ + + */ + public array $omittedFields = [], + ) { + } + + public function key(): string + { + return $this->id; + } + + public function __toString(): string + { + return sprintf('%s (%s)', $this->title, $this->id); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'description' => $this->description, + 'publisher' => $this->publisher, + 'contact' => $this->contact, + 'landing_page' => $this->landingPage, + 'access_url' => $this->accessUrl, + 'media_type' => $this->mediaType, + 'crs' => $this->crs, + 'model' => $this->model, + 'context_url' => $this->contextUrl, + 'update_frequency' => $this->updateFrequency, + 'licence' => $this->licence, + 'omitted_fields' => $this->omittedFields, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Source/MtmSpatialMaps/HandicapParking.php b/src/Source/MtmSpatialMaps/HandicapParking.php index 1612cf0..3ea503c 100644 --- a/src/Source/MtmSpatialMaps/HandicapParking.php +++ b/src/Source/MtmSpatialMaps/HandicapParking.php @@ -6,17 +6,15 @@ use App\Geo\Wgs84Transformer; use App\Ngsi\NgsiEntity; +use App\Source\AbstractSource; use App\Source\DataSourceReader; use App\Source\Manifest\Catalog; use App\Source\Manifest\Descriptor; -use App\Source\SourceInterface; /** * Disabled parking bays in Aarhus Municipality. - * - * @see config/sources.yaml */ -final readonly class HandicapParking implements SourceInterface +final readonly class HandicapParking extends AbstractSource { private const string KEY = 'mtm_spatialmaps-handicap-parking'; @@ -25,11 +23,29 @@ public function __construct( private Wgs84Transformer $transformer, private Catalog $catalog, ) { - } + parent::__construct( + id: 'mtm_spatialmaps-handicap-parking', + title: 'Handicapparkering, Aarhus Kommune', + description: 'Disabled parking bays in Aarhus Municipality, with the number of reserved bays per location.', + publisher: 'Aarhus Kommune', + contact: 'ppg@aarhus.dk', + landingPage: 'https://www.opendata.dk/city-of-aarhus/parkering-i-aarhus-kommune', + accessUrl: 'https://webkort.aarhuskommune.dk/spatialmap?page=get_geojson_opendata&datasource=invap', + mediaType: 'application/geo+json', + crs: 'EPSG:25832', + model: 'OnStreetParking', + contextUrl: 'https://raw.githubusercontent.com/smart-data-models/dataModel.Parking/master/context.jsonld', + updateFrequency: 'continuous', - public function key(): string - { - return self::KEY; + omittedFields: [ + 'ident' => 'Single-letter code; its meaning is not documented and not confirmed by the data owner.', + 'oprettet_af' => 'Directory username of the municipal employee who created the record.', + 'rettet_af' => 'Directory username of the municipal employee who last edited the record.', + 'oprettet_dato' => 'Describes the register record.', + 'rettet_dato' => 'Describes the register record.', + 'mi_style' => 'MapInfo rendering style, empty throughout the export.', + ], + ); } public function entities(): iterable diff --git a/src/Source/SourceInterface.php b/src/Source/SourceInterface.php index f985048..1d6d68d 100644 --- a/src/Source/SourceInterface.php +++ b/src/Source/SourceInterface.php @@ -15,15 +15,76 @@ * ENTER data set costs exactly one class. */ #[AutoconfigureTag('app.source')] -interface SourceInterface +interface SourceInterface extends \Stringable, \JsonSerializable { + public string $id { + get; + } + + public string $title { + get; + } + public string $description { + get; + } + public string $publisher { + get; + } + public string $contact { + get; + } + public string $landingPage { + get; + } + + // @todo access_url? What access? Isn't it just a URL? + public string $accessUrl { + get; + } + + public string $mediaType { + get; + } + + public string $crs { + get; + } + + public string $model { + get; + } + + public string $contextUrl { + get; + } + + public string $updateFrequency { + get; + } + + public ?string $licence { + get; + } + + // @todo What does this mean? + // Fields the feed carries that are not published. Recorded here because + // the source class shows what is mapped but cannot show what was left + // out, or why. /** - * Unique identifier for this source. + * @var array */ - public function key(): string; + public array $omittedFields { + get; + } /** * @return iterable */ + // We should let the (data) source reader read. public function entities(): iterable; + + /** + * @return array + */ + public function toArray(): array; } diff --git a/src/SourceManager.php b/src/SourceManager.php new file mode 100644 index 0000000..681ed07 --- /dev/null +++ b/src/SourceManager.php @@ -0,0 +1,61 @@ + + */ + private array $indexed; + + /** + * @param iterable $sources + */ + public function __construct( + #[AutowireIterator('app.source')] + private readonly iterable $sources, + ) { + } + + /** + * Get sources indexed by ID. + * + * @return array + */ + public function getSources(): array + { + if (!isset($this->indexed)) { + $sources = []; + foreach ($this->sources as $source) { + if (!$source instanceof SourceInterface) { + throw new \InvalidArgumentException(sprintf('Invalid source class: %s (must extend %s)', $source::class, AbstractSource::class)); + } + $id = $source->id; + if (isset($sources[$id])) { + throw new \RuntimeException(sprintf('Duplicate source: %s (ID already used by %s)', $id, $sources[$id]::class)); + } + $sources[$id] = $source; + } + $this->indexed = $sources; + } + + return $this->indexed; + } + + public function getSource(string $name): SourceInterface + { + $sources = $this->getSources(); + if (!array_key_exists($name, $sources)) { + throw new \InvalidArgumentException(sprintf('Source "%s" does not exist.', $name)); + } + + return $sources[$name]; + } +} diff --git a/src/ValueResolver/SourceArgumentValueResolver.php b/src/ValueResolver/SourceArgumentValueResolver.php new file mode 100644 index 0000000..756ab33 --- /dev/null +++ b/src/ValueResolver/SourceArgumentValueResolver.php @@ -0,0 +1,34 @@ + + */ + public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable + { + /** @var \ReflectionNamedType $type */ + $type = $member->getType(); + if (SourceInterface::class !== $type->getName()) { + return []; + } + $value = $input->getArgument($argumentName); + + return [ + $this->sourceManager->getSource($value), + ]; + } +} diff --git a/tests/Command/ImportCommandTest.php b/tests/Command/ImportCommandTest.php index 6d4c7ca..828e17f 100644 --- a/tests/Command/ImportCommandTest.php +++ b/tests/Command/ImportCommandTest.php @@ -33,7 +33,7 @@ private function tester(iterable $sources, ?MockHttpClient $client = null): Comm { $keys = []; foreach ($sources as $source) { - $keys[] = $source->key(); + $keys[] = $source->id; } return new CommandTester(new ImportCommand(new DataSourceImporter( diff --git a/tests/Import/DataSourceImporterTest.php b/tests/Import/DataSourceImporterTest.php index 3e838a7..6686577 100644 --- a/tests/Import/DataSourceImporterTest.php +++ b/tests/Import/DataSourceImporterTest.php @@ -35,7 +35,7 @@ private function importer( ): DataSourceImporter { $keys = []; foreach ($sources as $source) { - $keys[] = $source->key(); + $keys[] = $source->id; } return new DataSourceImporter(