Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 39 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?php

use App\Source\AbstractSource;

final readonly class MySource extends AbstractSource
{
public function __construct(
) {
parent::__construct(
id: 'my-source',
title: 'My source with some cool data',
description: '',
publisher: 'Me',
contact: 'me@example.com',
landingPage: 'https://my-data.example.com',
accessUrl: 'https://my-data.example.com/data',
mediaType: 'application/geo+json',
crs: 'EPSG:4326',
model: 'MyModel',
contextUrl: '',
updateFrequency: 'daily',
);
}

}
```

Run

Every data set is recorded in [config/sources.yaml](config/sources.yaml), keyed
by the identifier `app:import` takes as its argument. See [ADR 007](docs/adr/007-source-manifest.md).
``` shell
php bin/console app:source:list
```

Adding a data set means adding one `SourceInterface` implementation and one
manifest entry. The class is discovered through
`#[AutoconfigureTag('app.source')]` and shows up as an `app:import` argument
with no further wiring.
list all data sources.

Design decisions are recorded in [docs/adr](docs/adr/README.md).

Expand Down
2 changes: 2 additions & 0 deletions phpstan.dist.neon
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ parameters:
excludePaths:
- src/Kernel.php

treatPhpDocTypesAsCertain: false

ignoreErrors:
- message: '#Access to protected property proj4php\\Point::\$(x|y).#'
path: src/Command/BrokerImportGeoJson.php
Expand Down
28 changes: 28 additions & 0 deletions src/Command/SourceListCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace App\Command;

use App\SourceManager;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
name: 'app:source:list',
)]
class SourceListCommand
{
public function __invoke(
SymfonyStyle $io,
SourceManager $manager,
): int {
$sources = $manager->getSources();

$io->writeln(sprintf('#sources: %d', \count($sources)));
foreach ($sources as $source) {
$io->writeln((string) $source);
}

return Command::SUCCESS;
}
}
26 changes: 26 additions & 0 deletions src/Command/SourceReadCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace App\Command;

use App\Source\DataSourceReader;
use App\Source\SourceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
name: 'app:source:read',
)]
class SourceReadCommand
{
public function __invoke(SymfonyStyle $io,
DataSourceReader $reader,
#[Argument]
SourceInterface $source): int
{
throw new \RuntimeException('Lazy programmer exception!');
// $reader = $readerFactory->getReader($source);
// $reader->read($source);
// …
}
}
25 changes: 25 additions & 0 deletions src/Command/SourceShowCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Command;

use App\Source\SourceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Yaml\Yaml;

#[AsCommand(
name: 'app:source:show',
)]
class SourceShowCommand
{
public function __invoke(SymfonyStyle $io,
#[Argument('The source ID')]
SourceInterface $source): int
{
$io->writeln(Yaml::dump($source->toArray(), PHP_INT_MAX));

return Command::SUCCESS;
}
}
2 changes: 1 addition & 1 deletion src/Import/DataSourceImporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ private function registry(): array
$registry = [];

foreach ($this->sources as $source) {
$registry[$source->key()] = $source;
$registry[$source->id] = $source;
}

return $registry;
Expand Down
73 changes: 73 additions & 0 deletions src/Source/AbstractSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace App\Source;

/**
* Abstract source.
*
* Extend this to easily create a source implemting the SourceInterface via constructor arguments.
*/
abstract readonly class AbstractSource implements SourceInterface
{
public function __construct(
public string $id,
public string $title,
public string $description,
public string $publisher,
public string $contact,
public string $landingPage,
public string $accessUrl,
public string $mediaType,
public string $crs,
public string $model,
public string $contextUrl,
public string $updateFrequency,
public ?string $licence = null,
/**
* @var array<string, string>
*/
public array $omittedFields = [],
) {
}

public function key(): string
{
return $this->id;
}

public function __toString(): string
{
return sprintf('%s (%s)', $this->title, $this->id);
}

/**
* @return array<string, mixed>
*/
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<string, mixed>
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}
32 changes: 24 additions & 8 deletions src/Source/MtmSpatialMaps/HandicapParking.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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
Expand Down
67 changes: 64 additions & 3 deletions src/Source/SourceInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
*/
public function key(): string;
public array $omittedFields {
get;
}

/**
* @return iterable<NgsiEntity>
*/
// We should let the (data) source reader read.
public function entities(): iterable;

/**
* @return array<string, mixed>
*/
public function toArray(): array;
}
Loading