feat!: migrate to Valinor DTOs, add v3 YTJ API client, modernize tooling (#13)

* feat!: moved v1 api under src/v1

BREAKING CHANGE: please update your namespaces if you use any
of the classes directly or composer doesn't like the namespace
change.

* feat!: migrate from spatie/dto to valinor, add pest and rector

BREAKING CHANGE: replaced spatie/data-transfer-object with cuyz/valinor,
upgraded phpcs to v4, added pestphp/pest and rector/rector.
Removed ivuorinen/markdowndocs due to symfony/console conflict.

* feat: add shared HTTP AbstractClient with Valinor mapper

Base class providing Guzzle HTTP client and Valinor TreeMapper
with allowSuperfluousKeys() for PRH API response hydration.

* refactor!: migrate v1 DTOs to final readonly with Valinor hydration

BREAKING CHANGE: all v1 DTOs are now final readonly classes with
constructor promotion. Traits are method-only (properties removed).
HasVersion trait deleted. BusinessDataFetcher extends AbstractClient.

* feat: add v3 YTJ API client with DTOs

New client for PRH opendata-ytj-api v3 with full DTO coverage
matching the v3 OpenAPI schema.

* test: add Pest test suite for v1 and v3

Unit tests for all v1 DTOs, traits, and BusinessDataFetcher client.
Unit tests for v3 Client and Company DTO hydration.
Includes JSON fixtures for realistic response testing.

* chore: add Rector config with deadCode, codeQuality, typeDeclarations

* ci: update workflows to use ivuorinen/actions, pin SHAs, add PHP 8.4

Migrated from ivuorinen/.github reusable workflows to
ivuorinen/actions composite actions with pinned commit SHAs.
Added PHP 8.4 to test matrix. Updated renovate config preset path.
Added labels.yml for sync-labels workflow.

* chore: clean up .gitignore for PHP-only project

Removed irrelevant entries for Node.js, Next.js, Nuxt, Laravel,
Vagrant, Android/Crashlytics, CMake, and other unused ecosystems.

* docs: update README, add CHANGELOG, CONTRIBUTING, and CLAUDE.md

Rewrote README with v1/v3 usage examples and badges.
Added CHANGELOG, CONTRIBUTING guide, and CLAUDE.md project instructions.
Added .claude/ settings for Claude Code integration.

* chore(deps): update composer.lock

* chore: add CaptainHook with conventional commits and secrets detection

Added captainhook/captainhook, captainhook/hook-installer,
captainhook/secrets, and ramsey/conventional-commits.

Hooks configured:
- pre-commit: secrets check, lint-fix, test
- commit-msg: conventional commit validation
- post-change: composer install on lock/json changes

* fix(deps): regenerate composer.lock with PHP 8.2 compatibility

Downgraded symfony packages from v8 to v7.4 to support the
full PHP 8.2/8.3/8.4 test matrix.

* fix: address PR #13 code review feedback

- Pin captainhook versions (^5.0, ^1.0) instead of wildcard
- Add ext-json to composer.json require block
- Fix v3 Client base URI to avoid duplicate path prefix
- Handle null language in HasLanguage trait
- Remove empty-string defaults from structural DTO fields
- Remove branch-specific section from CLAUDE.md
- Fix vendor path deny pattern in .claude/settings.json
- Use phpcbf directly in post-edit lint hook
- Add null register test case
- Cast json_encode in ClientTest for type safety

* chore(deps): regenerate composer.lock with PHP 8.2

* fix: address PR #13 code review feedback (round 2)

- Downgrade stale.yml permissions from contents:write to contents:read
- Remove nullable arrays in BisCompanyDetails (PRH API always returns arrays)
- Mark 3 additional breaking changes in CHANGELOG.md
- Extract API_PREFIX constant in v3 Client to reduce path duplication

* fix(ci): resolve merge conflict markers in composer workflow

* fix(ci): clean up pr-lint workflow

* chore: add MegaLinter configuration

* docs: add PHPDoc to HTTP layer classes

* docs: add PHPDoc to v1 DTOs, traits, and exceptions

* docs: add PHPDoc to v3 DTOs and exceptions

* fix(ci): use Composer download cache instead of vendor cache

Caching vendor/ can inject stale binaries that pass unexpected
arguments (e.g. --cache-directory) to Pest. Switch to caching
~/.composer/cache which only stores download archives.

* fix(ci): add permissions to PR lint job

MegaLinter needs write access to issues, pull-requests, and statuses
to post results. Add explicit permissions block to the lint job.

* fix: add source directory to phpcs.xml

MegaLinter runs phpcs in project mode which relies on phpcs.xml to
know which directories to scan. Add <file>src</file> so it finds code.

* fix(deps): add SARIF and phpstan extension-installer packages

Add phpstan/extension-installer, jbelien/phpstan-sarif-formatter, and
bartlett/sarif-php-converters so MegaLinter can produce SARIF output
from phpstan.

* fix(ci): disable JSON_PRETTIER and configure markdownlint

- Disable JSON_PRETTIER linter in MegaLinter (not needed)
- Add .markdownlint.json disabling MD041 (first-line-heading false
  positive on YAML frontmatter)
- Wrap bare URLs in README.md with angle brackets (MD034)

* fix: add phpunit.xml.dist with cacheDirectory

Prevents Pest's Cache plugin from injecting --cache-directory argument
in CI, which caused PHPUnit to misparse it as a config file.

* fix(ci): fix MegaLinter config

Remove JSON_PRETTIER and PHP_PHPSTAN from ENABLE_LINTERS to resolve
conflicts with DISABLE_LINTERS and missing SARIF extension. Add .claude/
to FILTER_REGEX_EXCLUDE.

* fix: resolve markdownlint errors

Disable MD013 (line length), add blank lines around headings/lists in
CHANGELOG.md, fix table separator spacing in README.md.

* fix: harden v3 Client error handling and URL encoding

- Make BusinessLine::$code required (no empty-string default)
- URL-encode businessId in v1 client to prevent path injection
- Catch \JsonException alongside RequestException in v3 Client
  searchCompanies() and getPostCodes() since getJson() can throw it

* fix: pin dependency versions and enable workflow YAML linting

- Pin phpstan/extension-installer, jbelien/phpstan-sarif-formatter,
  and bartlett/sarif-php-converters to ^1.0 instead of wildcard
- Remove .github/ from MegaLinter FILTER_REGEX_EXCLUDE so YAML
  linters can check workflow files

* fix: resolve yamllint errors in GitHub YAML files

- Add missing document start marker to labels.yml
- Fix step indentation in composer.yml workflow

* fix: resolve markdownlint errors and disable YAML_PRETTIER

* chore: add yamllint configuration
This commit is contained in:
2026-03-07 13:28:39 +02:00
committed by GitHub
parent f025e1202d
commit 150c466368
94 changed files with 6602 additions and 949 deletions

View File

@@ -2,47 +2,37 @@
namespace Ivuorinen\BusinessDataFetcher;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Ivuorinen\BusinessDataFetcher\Dto\BisCompanyDetails;
use Ivuorinen\BusinessDataFetcher\Exceptions\ApiResponseErrorException;
use Ivuorinen\BusinessDataFetcher\Http\AbstractClient;
use Ivuorinen\BusinessDataFetcher\v1\Dto\BisCompanyDetails;
use Ivuorinen\BusinessDataFetcher\v1\Exceptions\ApiResponseErrorException;
use Psr\Http\Message\ResponseInterface;
/**
* Fetches and returns business data from avoindata
*/
class BusinessDataFetcher
/** Client for the PRH BIS v1 API (Finnish business data). */
class BusinessDataFetcher extends AbstractClient
{
/**
* @var \GuzzleHttp\Client
*/
private Client $httpClient;
/**
* BusinessDataFetcher constructor.
*/
public function __construct()
/** @inheritDoc */
protected function getBaseUri(): string
{
$this->httpClient = new Client([
'base_uri' => 'https://avoindata.prh.fi',
'timeout' => 2,
]);
return 'https://avoindata.prh.fi';
}
/** @inheritDoc */
protected function getTimeout(): int
{
return 2;
}
/**
* Fetch Business Information.
*
* @return BisCompanyDetails[] $response_data
* @return BisCompanyDetails[]
* @throws \Exception|\GuzzleHttp\Exception\GuzzleException
*/
public function getBusinessInformation(string $businessId): array
{
// Set request variables
$requestUrl = '/bis/v1';
// Get the business data
try {
$uri = $requestUrl . '/' . $businessId;
$uri = '/bis/v1/' . rawurlencode($businessId);
$response = $this->httpClient->get($uri);
if ($response->getStatusCode() !== 200) {
@@ -52,7 +42,7 @@ class BusinessDataFetcher
);
}
$response_data = $this->parseResponse($response);
return $this->parseResponse($response);
} catch (RequestException $exception) {
throw new ApiResponseErrorException(
$exception->getMessage(),
@@ -60,8 +50,6 @@ class BusinessDataFetcher
$exception
);
}
return $response_data;
}
/**
@@ -69,8 +57,7 @@ class BusinessDataFetcher
*
* @return BisCompanyDetails[]
* @throws \JsonException
* @throws \Spatie\DataTransferObject\Exceptions\UnknownProperties
* @throws \Ivuorinen\BusinessDataFetcher\Exceptions\ApiResponseErrorException
* @throws ApiResponseErrorException
*/
public function parseResponse(ResponseInterface $response): array
{
@@ -88,7 +75,7 @@ class BusinessDataFetcher
);
}
if (!isset($data['results'])) {
if (!isset($data['results']) || !is_array($data['results'])) {
throw new ApiResponseErrorException(
'Invalid response data',
$response->getStatusCode()
@@ -98,7 +85,7 @@ class BusinessDataFetcher
$results = [];
foreach ($data['results'] as $result) {
$results[] = new BisCompanyDetails($result);
$results[] = $this->mapper->map(BisCompanyDetails::class, $result);
}
return $results;

View File

@@ -1,56 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Dto;
use Spatie\DataTransferObject\DataTransferObject;
use Ivuorinen\BusinessDataFetcher\Traits;
/**
* Address
*/
class BisAddress extends DataTransferObject
{
use Traits\HasSource;
use Traits\HasVersion;
use Traits\HasLanguage;
/**
* Date of registration
*/
public string $registrationDate = '';
/**
* Ending date of registration
*/
public ?string $endDate = null;
/**
* Care of address
*/
public ?string $careOf = null;
/**
* Street address
*/
public ?string $street = null;
/**
* ZIP code
*/
public ?string $postCode = null;
/**
* City of address
*/
public ?string $city = null;
/**
* Type of address, 1 for street address, 2 for postal address
*/
public int $type;
/**
* Two letter country code
*/
public ?string $country = null;
}

View File

@@ -1,41 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Dto;
use Spatie\DataTransferObject\DataTransferObject;
use Ivuorinen\BusinessDataFetcher\Traits;
/**
* Company Business Id Change
*/
class BisCompanyBusinessIdChange extends DataTransferObject
{
use Traits\HasSource;
use Traits\HasLanguage;
use Traits\HasChange;
/**
* Description of reason
*/
public string $description = '';
/**
* Reason code
*/
public string $reason = '';
/**
* Date of Business ID change
*/
public ?string $changeDate = null;
/**
* Old Business ID
*/
public string $oldBusinessId = '';
/**
* New Business ID
*/
public string $newBusinessId = '';
}

View File

@@ -1,36 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Dto;
use Spatie\DataTransferObject\DataTransferObject;
use Ivuorinen\BusinessDataFetcher\Traits;
/**
* Company Business Line
*/
class BisCompanyBusinessLine extends DataTransferObject
{
use Traits\HasSource;
use Traits\HasVersion;
use Traits\HasLanguage;
/**
* Zero for main line of business, positive for others
*/
public int $order;
/**
* Date of registration
*/
public string $registrationDate = '';
/**
* Ending date of registration
*/
public ?string $endDate = null;
/**
* Name of line of business
*/
public string $name = '';
}

View File

@@ -1,36 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Dto;
use Spatie\DataTransferObject\DataTransferObject;
use Ivuorinen\BusinessDataFetcher\Traits;
/**
* Company Contact Detail
*/
class BisCompanyContactDetail extends DataTransferObject
{
use Traits\HasSource;
use Traits\HasVersion;
use Traits\HasLanguage;
/**
* Date of registration
*/
public string $registrationDate = '';
/**
* Ending date of registration
*/
public ?string $endDate = null;
/**
* Value of contact detail
*/
public string $value = '';
/**
* Type of contact detail
*/
public string $type = '';
}

View File

@@ -1,115 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Dto;
use Spatie\DataTransferObject\DataTransferObject;
use Spatie\DataTransferObject\Attributes\CastWith;
use Spatie\DataTransferObject\Casters;
/**
* Company Details
*/
class BisCompanyDetails extends DataTransferObject
{
/**
* Primary company name and translations
* @var BisCompanyName[] $names
*/
#[CastWith(Casters\ArrayCaster::class, itemType: BisCompanyName::class)]
public array $names = [];
/**
* Auxiliary company name and translations
* @var ?BisCompanyName[] $auxiliaryNames
*/
#[CastWith(Casters\ArrayCaster::class, itemType: BisCompanyName::class)]
public ?array $auxiliaryNames = [];
/**
* Company's street and postal addresses
* @var ?BisAddress[] $addresses
*/
#[CastWith(Casters\ArrayCaster::class, itemType: BisAddress::class)]
public ?array $addresses = [];
/**
* Company form and translations
* @var ?BisCompanyForm[] $companyForms
*/
#[CastWith(Casters\ArrayCaster::class, itemType: BisCompanyForm::class)]
public ?array $companyForms = [];
/**
* Bankruptcy, liquidation or restructuring proceedings
* @var ?BisCompanyLiquidation[] $liquidations
*/
#[CastWith(Casters\ArrayCaster::class, itemType: BisCompanyLiquidation::class)]
public ?array $liquidations = [];
/**
* Company's lines of business and translations
* @var ?BisCompanyBusinessLine[] $businessLines
*/
#[CastWith(Casters\ArrayCaster::class, itemType: BisCompanyBusinessLine::class)]
public ?array $businessLines = [];
/**
* Company's language(s)
* @var ?BisCompanyLanguage[] $languages
*/
#[CastWith(Casters\ArrayCaster::class, itemType: BisCompanyLanguage::class)]
public ?array $languages = [];
/**
* Company's place of registered office and its translations
* @var ?BisCompanyRegisteredOffice[] $registeredOffices
*/
#[CastWith(Casters\ArrayCaster::class, itemType: BisCompanyRegisteredOffice::class)]
public ?array $registeredOffices = [];
/**
* Company's contact details and translations
* @var ?BisCompanyContactDetail[] $contactDetails
*/
#[CastWith(Casters\ArrayCaster::class, itemType: BisCompanyContactDetail::class)]
public ?array $contactDetails = [];
/**
* Company's registered entries
* @var ?BisCompanyRegisteredEntry[] $registeredEntries
*/
#[CastWith(Casters\ArrayCaster::class, itemType: BisCompanyRegisteredEntry::class)]
public ?array $registeredEntries = [];
/**
* Company's Business ID changes
* @var ?BisCompanyBusinessIdChange[] $businessIdChanges
*/
#[CastWith(Casters\ArrayCaster::class, itemType: BisCompanyBusinessIdChange::class)]
public ?array $businessIdChanges = [];
/**
* Business ID
*/
public string $businessId = '';
/**
* Date of registration
*/
public string $registrationDate = '';
/**
* Company form
*/
public ?string $companyForm = null;
/**
* A URI for more details, if details aren't already included
*/
public ?string $detailsUri = null;
/**
* Primary company name
*/
public string $name = '';
}

View File

@@ -1,36 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Dto;
use Spatie\DataTransferObject\DataTransferObject;
use Ivuorinen\BusinessDataFetcher\Traits;
/**
* Company Form
*/
class BisCompanyForm extends DataTransferObject
{
use Traits\HasSource;
use Traits\HasVersion;
use Traits\HasLanguage;
/**
* Date of registration
*/
public string $registrationDate = '';
/**
* Ending date of registration
*/
public ?string $endDate = null;
/**
* Name of company form
*/
public string $name = '';
/**
* Type of company form
*/
public ?string $type = null;
}

View File

@@ -1,31 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Dto;
use Spatie\DataTransferObject\DataTransferObject;
use Ivuorinen\BusinessDataFetcher\Traits;
/**
* Company Language
*/
class BisCompanyLanguage extends DataTransferObject
{
use Traits\HasSource;
use Traits\HasVersion;
use Traits\HasLanguage;
/**
* Date of registration
*/
public string $registrationDate = '';
/**
* Ending date of registration
*/
public ?string $endDate = null;
/**
* Name of language
*/
public string $name = '';
}

View File

@@ -1,36 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Dto;
use Spatie\DataTransferObject\DataTransferObject;
use Ivuorinen\BusinessDataFetcher\Traits;
/**
* Company Liquidation
*/
class BisCompanyLiquidation extends DataTransferObject
{
use Traits\HasSource;
use Traits\HasVersion;
use Traits\HasLanguage;
/**
* Date of registration
*/
public string $registrationDate = '';
/**
* Ending date of registration
*/
public ?string $endDate = null;
/**
* Bankruptcy, liquidation or restructuring proceedings
*/
public string $name = '';
/**
* Type of liquidation
*/
public string $type = '';
}

View File

@@ -1,36 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Dto;
use Spatie\DataTransferObject\DataTransferObject;
use Ivuorinen\BusinessDataFetcher\Traits;
/**
* Company Name
*/
class BisCompanyName extends DataTransferObject
{
use Traits\HasSource;
use Traits\HasVersion;
use Traits\HasLanguage;
/**
* Zero for primary company name, other for translations of the primary company name and auxiliary company names
*/
public int $order;
/**
* Date of registration
*/
public string $registrationDate = '';
/**
* Ending date of registration
*/
public ?string $endDate = null;
/**
* Company name
*/
public string $name = '';
}

View File

@@ -1,36 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Dto;
use Spatie\DataTransferObject\DataTransferObject;
use Ivuorinen\BusinessDataFetcher\Traits;
/**
* Company Registered Entry
*/
class BisCompanyRegisteredEntry extends DataTransferObject
{
use Traits\HasAuthority;
use Traits\HasLanguage;
use Traits\HasRegister;
/**
* Description of entry
*/
public string $description = '';
/**
* Zero for common entries, one for Unregistered and two for Registered
*/
public int $status;
/**
* Date of registration
*/
public string $registrationDate = '';
/**
* Ending date of registration
*/
public ?string $endDate = null;
}

View File

@@ -1,36 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Dto;
use Spatie\DataTransferObject\DataTransferObject;
use Ivuorinen\BusinessDataFetcher\Traits;
/**
* Company Registered Office
*/
class BisCompanyRegisteredOffice extends DataTransferObject
{
use Traits\HasSource;
use Traits\HasVersion;
use Traits\HasLanguage;
/**
* Zero for primary place of registered office, positive for others
*/
public int $order;
/**
* Date of registration
*/
public string $registrationDate = '';
/**
* Ending date of registration
*/
public ?string $endDate = null;
/**
* Name of place of registered office
*/
public string $name = '';
}

View File

@@ -1,9 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Exceptions;
use Exception;
class ApiResponseErrorException extends Exception
{
}

View File

@@ -1,9 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Exceptions;
use Exception;
class UnexpectedValueException extends Exception
{
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Http;
use CuyZ\Valinor\Mapper\TreeMapper;
use CuyZ\Valinor\MapperBuilder;
use GuzzleHttp\Client;
/**
* Base HTTP client for PRH API communication.
*
* Provides shared Guzzle HTTP client and Valinor mapper instances
* for concrete API client implementations.
*/
abstract class AbstractClient
{
protected Client $httpClient;
protected TreeMapper $mapper;
/** Initialize HTTP client and Valinor mapper. */
public function __construct(?Client $httpClient = null)
{
$this->httpClient = $httpClient ?? HttpClientFactory::create(
$this->getBaseUri(),
$this->getTimeout()
);
$this->mapper = (new MapperBuilder())
->allowSuperfluousKeys()
->mapper();
}
/** Get the base URI for the API. */
abstract protected function getBaseUri(): string;
/** Get the HTTP request timeout in seconds. */
protected function getTimeout(): int
{
return 10;
}
/**
* Perform a GET request and decode the JSON response.
*
* @param array<string, mixed> $query
* @return array<mixed>
* @throws \GuzzleHttp\Exception\GuzzleException
* @throws \JsonException
*/
protected function getJson(string $uri, array $query = []): array
{
$options = [];
if ($query !== []) {
$options['query'] = $query;
}
$response = $this->httpClient->get($uri, $options);
$data = json_decode(
$response->getBody()->getContents(),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($data)) {
throw new \JsonException('Response is not a valid JSON object or array');
}
return $data;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Http;
use GuzzleHttp\Client;
/** Factory for creating pre-configured Guzzle HTTP clients. */
class HttpClientFactory
{
/** Create a Guzzle client with the given base URI and timeout. */
public static function create(string $baseUri, int $timeout = 10): Client
{
return new Client([
'base_uri' => $baseUri,
'timeout' => $timeout,
]);
}
}

View File

@@ -1,26 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Traits;
trait HasLanguage
{
/**
* @see getLanguageString()
* @var string|null $language Two letter language code
* (e.g. 'fi', 'sv', 'en')
*/
public ?string $language;
/**
* Get the language code as a string.
*/
public function getLanguageString(): string
{
return match ($this->language) {
'fi' => 'finnish',
'en' => 'english',
'sv' => 'swedish',
default => 'unknown:' . $this->language,
};
}
}

View File

@@ -1,34 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Traits;
trait HasSource
{
/**
* Source of the information.
*
* source (integer, optional):
* - Zero for common,
* - one for Finnish Patent and Registration Office,
* - two for Tax Administration or
* - three for Business Information System
*
* Use `getSourceText()` to get the text representation.
*
* @see getSourceText()
*
* @var int|null
*/
public ?int $source;
public function getSourceText(): string
{
return match ($this->source) {
0 => 'common',
1 => 'Finnish Patent and Registration Office',
2 => 'Tax Administration',
3 => 'Business Information System',
default => '',
};
}
}

View File

@@ -1,11 +0,0 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Traits;
trait HasVersion
{
/**
* One for current version and >1 for historical contact details.
*/
public int $version = 0;
}

27
src/v1/Dto/BisAddress.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Dto;
use Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Represents a postal or visiting address from the BIS v1 API. */
final readonly class BisAddress
{
use Traits\HasSource;
use Traits\HasLanguage;
public function __construct(
public int $type = 0,
public string $registrationDate = '',
public ?string $endDate = null,
public ?string $careOf = null,
public ?string $street = null,
public ?string $postCode = null,
public ?string $city = null,
public ?string $country = null,
public ?int $source = null,
public int $version = 0,
public ?string $language = null,
) {
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Dto;
use Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Represents a business ID change event (e.g. fusion, division). */
final readonly class BisCompanyBusinessIdChange
{
use Traits\HasSource;
use Traits\HasLanguage;
use Traits\HasChange;
public function __construct(
public string $description = '',
public string $reason = '',
public ?string $changeDate = null,
public string $oldBusinessId = '',
public string $newBusinessId = '',
public ?int $source = null,
public ?string $language = null,
public string|int|null $change = null,
) {
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Dto;
use Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Represents a company's line of business (industry classification). */
final readonly class BisCompanyBusinessLine
{
use Traits\HasSource;
use Traits\HasLanguage;
public function __construct(
public int $order = 0,
public string $registrationDate = '',
public ?string $endDate = null,
public string $name = '',
public ?int $source = null,
public int $version = 0,
public ?string $language = null,
) {
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Dto;
use Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Represents a company contact detail (phone, email, website, etc.). */
final readonly class BisCompanyContactDetail
{
use Traits\HasSource;
use Traits\HasLanguage;
public function __construct(
public string $registrationDate = '',
public ?string $endDate = null,
public string $value = '',
public string $type = '',
public ?int $source = null,
public int $version = 0,
public ?string $language = null,
) {
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Dto;
/** Top-level DTO for a company record from the BIS v1 API. */
final readonly class BisCompanyDetails
{
/**
* @param list<BisCompanyName> $names
* @param list<BisCompanyName> $auxiliaryNames
* @param list<BisAddress> $addresses
* @param list<BisCompanyForm> $companyForms
* @param list<BisCompanyLiquidation> $liquidations
* @param list<BisCompanyBusinessLine> $businessLines
* @param list<BisCompanyLanguage> $languages
* @param list<BisCompanyRegisteredOffice> $registeredOffices
* @param list<BisCompanyContactDetail> $contactDetails
* @param list<BisCompanyRegisteredEntry> $registeredEntries
* @param list<BisCompanyBusinessIdChange> $businessIdChanges
*/
public function __construct(
public string $businessId = '',
public string $registrationDate = '',
public ?string $companyForm = null,
public ?string $detailsUri = null,
public string $name = '',
public array $names = [],
public array $auxiliaryNames = [],
public array $addresses = [],
public array $companyForms = [],
public array $liquidations = [],
public array $businessLines = [],
public array $languages = [],
public array $registeredOffices = [],
public array $contactDetails = [],
public array $registeredEntries = [],
public array $businessIdChanges = [],
) {
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Dto;
use Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Represents a company's legal form (e.g. Ltd, cooperative). */
final readonly class BisCompanyForm
{
use Traits\HasSource;
use Traits\HasLanguage;
public function __construct(
public string $registrationDate = '',
public ?string $endDate = null,
public string $name = '',
public ?string $type = null,
public ?int $source = null,
public int $version = 0,
public ?string $language = null,
) {
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Dto;
use Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Represents a company's registered language. */
final readonly class BisCompanyLanguage
{
use Traits\HasSource;
use Traits\HasLanguage;
public function __construct(
public string $registrationDate = '',
public ?string $endDate = null,
public string $name = '',
public ?int $source = null,
public int $version = 0,
public ?string $language = null,
) {
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Dto;
use Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Represents a company liquidation or bankruptcy entry. */
final readonly class BisCompanyLiquidation
{
use Traits\HasSource;
use Traits\HasLanguage;
public function __construct(
public string $registrationDate = '',
public ?string $endDate = null,
public string $name = '',
public string $type = '',
public ?int $source = null,
public int $version = 0,
public ?string $language = null,
) {
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Dto;
use Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Represents a company name or auxiliary name entry. */
final readonly class BisCompanyName
{
use Traits\HasSource;
use Traits\HasLanguage;
public function __construct(
public int $order = 0,
public string $registrationDate = '',
public ?string $endDate = null,
public string $name = '',
public ?int $source = null,
public int $version = 0,
public ?string $language = null,
) {
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Dto;
use Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Represents a registration entry in a public register. */
final readonly class BisCompanyRegisteredEntry
{
use Traits\HasAuthority;
use Traits\HasLanguage;
use Traits\HasRegister;
public function __construct(
public string $description = '',
public int $status = 0,
public string $registrationDate = '',
public ?string $endDate = null,
public int $authority = 0,
public ?string $language = null,
public int|null $register = null,
) {
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Dto;
use Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Represents a company's registered office (domicile). */
final readonly class BisCompanyRegisteredOffice
{
use Traits\HasSource;
use Traits\HasLanguage;
public function __construct(
public int $order = 0,
public string $registrationDate = '',
public ?string $endDate = null,
public string $name = '',
public ?int $source = null,
public int $version = 0,
public ?string $language = null,
) {
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Exceptions;
use Exception;
/** Thrown when the BIS v1 API returns an error response. */
class ApiResponseErrorException extends Exception
{
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Exceptions;
use Exception;
/** Thrown when the BIS v1 API returns an unexpected value. */
class UnexpectedValueException extends Exception
{
}

View File

@@ -1,18 +1,11 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Traits;
namespace Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Provides a human-readable authority name from the authority code. */
trait HasAuthority
{
/**
* @see getChangeString()
* @var int $authority What authority the change is related to.
*/
public int $authority;
/**
* Get the name of the authority.
*/
/** Get the authority name (e.g. "Tax Administration"). */
public function getAuthorityString(): string
{
return match ($this->authority) {

View File

@@ -1,19 +1,11 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Traits;
namespace Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Provides a human-readable description of a business ID change reason. */
trait HasChange
{
/**
* @see getChangeString()
* @var string|int|null $change Change as a string or integer.
* Models claim this is an integer, but it can also be a string.
*/
public string|int|null $change;
/**
* Get the description string of the change.
*/
/** Get the change reason description (e.g. "Fusion", "Division"). */
public function getChangeString(): string
{
return match ($this->change) {

View File

@@ -0,0 +1,22 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Provides a human-readable language name from a language code. */
trait HasLanguage
{
/** Get the language name (e.g. "finnish", "english", "swedish"). */
public function getLanguageString(): string
{
if ($this->language === null) {
return 'unknown';
}
return match ($this->language) {
'fi' => 'finnish',
'en' => 'english',
'sv' => 'swedish',
default => 'unknown:' . $this->language,
};
}
}

View File

@@ -1,18 +1,11 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\Traits;
namespace Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Provides a human-readable register name from the register code. */
trait HasRegister
{
/**
* @see getRegisterString()
* @var int|null $register What register the change is related to.
*/
public int|null $register;
/**
* Get the name of the register.
*/
/** Get the register name (e.g. "Trade Register", "VAT Register"). */
public function getRegisterString(): string
{
return match ($this->register) {

View File

@@ -0,0 +1,19 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v1\Traits;
/** Provides a human-readable data source name from the source code. */
trait HasSource
{
/** Get the data source name (e.g. "Tax Administration", "PRH"). */
public function getSourceText(): string
{
return match ($this->source) {
0 => 'common',
1 => 'Finnish Patent and Registration Office',
2 => 'Tax Administration',
3 => 'Business Information System',
default => '',
};
}
}

124
src/v3/Client.php Normal file
View File

@@ -0,0 +1,124 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3;
use GuzzleHttp\Exception\RequestException;
use Ivuorinen\BusinessDataFetcher\Http\AbstractClient;
use Ivuorinen\BusinessDataFetcher\v3\Dto\CompanySearchResult;
use Ivuorinen\BusinessDataFetcher\v3\Dto\PostCodeEntry;
use Ivuorinen\BusinessDataFetcher\v3\Exceptions\V3ApiException;
use Psr\Http\Message\StreamInterface;
/** Client for the PRH YTJ v3 API (Finnish business data). */
class Client extends AbstractClient
{
private const API_PREFIX = '/opendata-ytj-api/v3';
/** @inheritDoc */
protected function getBaseUri(): string
{
return 'https://avoindata.prh.fi';
}
/**
* Search for companies.
*
* @throws V3ApiException
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function searchCompanies(
?string $name = null,
?string $businessId = null,
?string $location = null,
?string $companyForm = null,
?string $mainBusinessLine = null,
?string $registrationDateStart = null,
?string $registrationDateEnd = null,
?string $postCode = null,
?string $businessIdRegistrationStart = null,
?string $businessIdRegistrationEnd = null,
?int $page = null,
): CompanySearchResult {
$query = array_filter([
'name' => $name,
'businessId' => $businessId,
'location' => $location,
'companyForm' => $companyForm,
'mainBusinessLine' => $mainBusinessLine,
'registrationDateStart' => $registrationDateStart,
'registrationDateEnd' => $registrationDateEnd,
'postCode' => $postCode,
'businessIdRegistrationStart' => $businessIdRegistrationStart,
'businessIdRegistrationEnd' => $businessIdRegistrationEnd,
'page' => $page,
], fn (string|int|null $v): bool => $v !== null);
try {
$data = $this->getJson(self::API_PREFIX . '/companies', $query);
return $this->mapper->map(CompanySearchResult::class, $data);
} catch (RequestException | \JsonException $e) {
throw new V3ApiException($e->getMessage(), (int) $e->getCode(), $e);
}
}
/**
* Retrieve code list description.
*
* @throws V3ApiException
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function getDescription(string $code, string $lang = 'en'): string
{
try {
$response = $this->httpClient->get(self::API_PREFIX . '/description', [
'query' => ['code' => $code, 'lang' => $lang],
]);
return $response->getBody()->getContents();
} catch (RequestException $e) {
throw new V3ApiException($e->getMessage(), $e->getCode(), $e);
}
}
/**
* Retrieve postal code details.
*
* @return PostCodeEntry[]
* @throws V3ApiException
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function getPostCodes(string $lang = 'en'): array
{
try {
$data = $this->getJson(self::API_PREFIX . '/post_codes', ['lang' => $lang]);
$results = [];
foreach ($data as $entry) {
$results[] = $this->mapper->map(PostCodeEntry::class, $entry);
}
return $results;
} catch (RequestException | \JsonException $e) {
throw new V3ApiException($e->getMessage(), (int) $e->getCode(), $e);
}
}
/**
* Get all companies as a ZIP download stream.
*
* @throws V3ApiException
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function getAllCompanies(): StreamInterface
{
try {
$response = $this->httpClient->get(self::API_PREFIX . '/all_companies', [
'stream' => true,
]);
return $response->getBody();
} catch (RequestException $e) {
throw new V3ApiException($e->getMessage(), $e->getCode(), $e);
}
}
}

28
src/v3/Dto/Address.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Represents a postal or visiting address from the YTJ v3 API. */
final readonly class Address
{
/**
* @param list<PostOffice> $postOffices
*/
public function __construct(
public int $type = 0,
public string $source = '',
public ?string $street = null,
public ?string $postCode = null,
public array $postOffices = [],
public ?string $postOfficeBox = null,
public ?string $buildingNumber = null,
public ?string $entrance = null,
public ?string $apartmentNumber = null,
public ?string $apartmentIdSuffix = null,
public ?string $co = null,
public ?string $country = null,
public ?string $freeAddressLine = null,
public ?string $registrationDate = null,
) {
}
}

14
src/v3/Dto/BusinessId.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Represents a Finnish business ID (Y-tunnus) with registration metadata. */
final readonly class BusinessId
{
public function __construct(
public string $value,
public ?string $registrationDate = null,
public ?string $source = null,
) {
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Represents a company's line of business (industry classification) from the YTJ v3 API. */
final readonly class BusinessLine
{
/**
* @param list<DescriptionEntry> $descriptions
*/
public function __construct(
public string $code,
public array $descriptions = [],
public ?string $typeCodeSet = null,
public ?string $registrationDate = null,
public ?string $source = null,
) {
}
}

32
src/v3/Dto/Company.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Top-level DTO for a company record from the YTJ v3 API. */
final readonly class Company
{
/**
* @param list<RegisterName> $names
* @param list<CompanyForm> $companyForms
* @param list<CompanySituation> $companySituations
* @param list<RegisteredEntry> $registeredEntries
* @param list<Address> $addresses
*/
public function __construct(
public BusinessId $businessId,
public string $tradeRegisterStatus = '',
public string $lastModified = '',
public ?EuId $euId = null,
public array $names = [],
public ?BusinessLine $mainBusinessLine = null,
public ?Website $website = null,
public array $companyForms = [],
public array $companySituations = [],
public array $registeredEntries = [],
public array $addresses = [],
public ?string $status = null,
public ?string $registrationDate = null,
public ?string $endDate = null,
) {
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Represents a company's legal form (e.g. Ltd, cooperative) from the YTJ v3 API. */
final readonly class CompanyForm
{
/**
* @param list<DescriptionEntry> $descriptions
*/
public function __construct(
public string $type = '',
public string $source = '',
public int $version = 0,
public array $descriptions = [],
public ?string $registrationDate = null,
public ?string $endDate = null,
) {
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Paginated search result containing matched companies from the YTJ v3 API. */
final readonly class CompanySearchResult
{
/**
* @param list<Company> $companies
*/
public function __construct(
public int $totalResults = 0,
public array $companies = [],
) {
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Represents a company's operational situation (e.g. active, dissolved). */
final readonly class CompanySituation
{
public function __construct(
public string $type,
public string $source,
public ?string $registrationDate = null,
public ?string $endDate = null,
) {
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Represents a localized description with a language code. */
final readonly class DescriptionEntry
{
public function __construct(
public string $languageCode = '',
public string $description = '',
) {
}
}

13
src/v3/Dto/EuId.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Represents a company's EU VAT identification number. */
final readonly class EuId
{
public function __construct(
public string $value = '',
public ?string $source = null,
) {
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Represents a Finnish postal code with associated city and municipality. */
final readonly class PostCodeEntry
{
public function __construct(
public string $postCode = '',
public string $city = '',
public bool $active = true,
public string $languageCode = '',
public ?string $municipalityCode = null,
) {
}
}

14
src/v3/Dto/PostOffice.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Represents a localized post office name within an address. */
final readonly class PostOffice
{
public function __construct(
public string $city,
public string $languageCode,
public ?string $municipalityCode = null,
) {
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Represents a company name registered in a specific register. */
final readonly class RegisterName
{
public function __construct(
public string $name = '',
public string $type = '',
public string $source = '',
public int $version = 0,
public ?string $registrationDate = null,
public ?string $endDate = null,
) {
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Represents a registration entry in a public register from the YTJ v3 API. */
final readonly class RegisteredEntry
{
/**
* @param list<DescriptionEntry> $descriptions
*/
public function __construct(
public string $type = '',
public string $register = '',
public string $authority = '',
public array $descriptions = [],
public ?string $registrationDate = null,
public ?string $endDate = null,
) {
}
}

14
src/v3/Dto/Website.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Dto;
/** Represents a company's registered website URL. */
final readonly class Website
{
public function __construct(
public string $url,
public ?string $registrationDate = null,
public ?string $source = null,
) {
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Ivuorinen\BusinessDataFetcher\v3\Exceptions;
use Exception;
/** Thrown when the YTJ v3 API returns an error response. */
class V3ApiException extends Exception
{
}