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

31
.claude/settings.json Normal file
View File

@@ -0,0 +1,31 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'input=$(cat); fp=$(echo \"$input\" | jq -r \".tool_input.file_path\"); if [[ \"$fp\" == *composer.lock ]] || [[ \"$fp\" == vendor/* ]] || [[ \"$fp\" == */vendor/* ]]; then echo \"{\\\"decision\\\":\\\"deny\\\",\\\"reason\\\":\\\"Do not edit $fp directly\\\"}\" >&2; exit 2; fi'"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'input=$(cat); fp=$(echo \"$input\" | jq -r \".tool_input.file_path\"); if [[ \"$fp\" == *.php ]]; then cd \"$CLAUDE_PROJECT_DIR\" && php vendor/bin/phpcbf --standard=PSR12 \"$fp\" 2>/dev/null; fi; true'"
},
{
"type": "command",
"command": "bash -c 'input=$(cat); fp=$(echo \"$input\" | jq -r \".tool_input.file_path\"); if [[ \"$fp\" == *.php ]]; then cd \"$CLAUDE_PROJECT_DIR\" && composer phpstan 2>&1 | tail -5; fi; true'",
"timeout": 30
}
]
}
]
}
}

View File

@@ -0,0 +1,13 @@
---
name: check-quality
description: Run lint, phpstan, and tests in sequence, fix any issues found
disable-model-invocation: true
---
Run the full quality check pipeline:
1. `composer lint` — fix any PSR-12 violations with `composer lint-fix`
2. `composer phpstan` — fix any static analysis issues
3. `composer test` — fix any failing tests
After each step, if issues are found, fix them before proceeding to the next step.
Do not commit. Report results when done.

View File

@@ -0,0 +1,16 @@
---
name: gen-test
description: Generate a Pest test for a PHP class following project conventions
disable-model-invocation: true
---
Generate a Pest test file for the specified class.
Rules:
- Place tests in `tests/Unit/` mirroring the src/ structure
- Use Pest syntax (test(), expect(), describe())
- For DTOs: test construction with valid data, verify all readonly properties
- For traits: test each method the trait provides
- For clients: mock Guzzle responses, test Valinor hydration
- Follow existing test patterns in tests/Unit/
- Read existing tests first to match style

View File

@@ -0,0 +1,15 @@
---
name: new-dto
description: Create a new final readonly DTO class with Valinor-compatible constructor
disable-model-invocation: true
---
Create a new DTO class following project conventions:
- `final readonly class` with constructor promotion
- Place in `src/v1/Dto/` or `src/v3/Dto/` based on API version
- All properties as constructor-promoted readonly parameters
- Use appropriate PHP types (string, int, ?string for nullable)
- Apply relevant traits (HasSource, HasLanguage, etc.) if applicable
- Namespace: `Ivuorinen\BusinessDataFetcher\{v1|v3}\Dto`
- Generate a corresponding Pest test in tests/Unit/
- Read existing DTOs first to match style

28
.github/labels.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
---
- name: bug
color: d73a4a
description: Something isn't working
- name: enhancement
color: a2eeef
description: New feature or request
- name: documentation
color: 0075ca
description: Improvements or additions to documentation
- name: dependencies
color: 0366d6
description: Pull requests that update a dependency file
- name: breaking-change
color: e11d48
description: Breaking change to public API
- name: maintenance
color: fbca04
description: Code maintenance and refactoring
- name: ci
color: 7057ff
description: Continuous integration and tooling

View File

@@ -1,4 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>ivuorinen/.github:renovate-config"]
"extends": ["github>ivuorinen/renovate-config"]
}

View File

@@ -18,33 +18,36 @@ jobs:
strategy:
matrix:
php-versions: ['8.2', '8.3']
php-versions: ['8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 # v2
with:
php-version: ${{ matrix.php-versions }}
- uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
with:
php-version: ${{ matrix.php-versions }}
- name: Validate composer.json and composer.lock
run: composer validate --strict
- name: Validate composer.json and composer.lock
run: composer validate --strict
- name: Cache Composer packages
id: composer-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5
with:
path: vendor
key: ${{ runner.os }}-php-${{ matrix.php-versions }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-php-${{ matrix.php-versions }}-
${{ runner.os }}-php-
- name: Cache Composer packages
id: composer-cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.composer/cache
key: ${{ runner.os }}-composer-${{ matrix.php-versions }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-${{ matrix.php-versions }}-
${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Install dependencies
run: composer install --prefer-dist --no-progress
# Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit"
# Docs: https://getcomposer.org/doc/articles/scripts.md
- name: Run linting
run: composer lint
# - name: Run test suite
# run: composer run-script test
- name: Run static analysis
run: composer phpstan
- name: Run test suite
run: composer test

View File

@@ -2,12 +2,19 @@
name: PR Lint
on:
push:
branches-ignore: [master, main]
# Remove the line above to run when pushing to master
pull_request:
branches: [master, main]
jobs:
SuperLinter:
uses: ivuorinen/.github/.github/workflows/pr-lint.yml@main
lint:
permissions:
contents: read
issues: write
pull-requests: write
statuses: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ivuorinen/actions/pr-lint@d1af04260d903f572ee953cc790ff7c1410709a6 # v2026.03.05
with:
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,10 +1,18 @@
---
name: Release Drafter
name: Release Monthly
# yamllint disable-line rule:truthy
on:
workflow_call:
workflow_dispatch:
permissions:
contents: write
jobs:
Draft:
uses: ivuorinen/.github/.github/workflows/sync-labels.yml@main
release:
runs-on: ubuntu-latest
steps:
- uses: ivuorinen/actions/release-monthly@d1af04260d903f572ee953cc790ff7c1410709a6 # v2026.03.05
with:
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -8,12 +8,15 @@ on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
permissions:
contents: write # only for delete-branch option
issues: write
pull-requests: write
steps:
- uses: ivuorinen/actions/stale@main
- uses: ivuorinen/actions/stale@d1af04260d903f572ee953cc790ff7c1410709a6 # v2026.03.05
with:
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -18,4 +18,10 @@ permissions:
jobs:
SyncLabels:
uses: ivuorinen/.github/.github/workflows/sync-labels.yml@main
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: ivuorinen/actions/sync-labels@d1af04260d903f572ee953cc790ff7c1410709a6 # v2026.03.05
with:
labels: .github/labels.yml
token: ${{ secrets.GITHUB_TOKEN }}

124
.gitignore vendored
View File

@@ -1,90 +1,49 @@
.php-cs-fixer.cache
.php-cs-fixer.php
# PHP / Composer
composer.phar
/vendor/
# PHP CS Fixer
.php-cs-fixer.cache
.php-cs-fixer.php
# PHPUnit / Pest
.phpunit.result.cache
.phpunit.cache
/app/phpunit.xml
/phpunit.xml
# Build artifacts
/build/
logs
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
pids
*.pid
*.seed
*.pid.lock
lib-cov
coverage
*.lcov
.nyc_output
.grunt
bower_components
.lock-wscript
build/Release
node_modules/
jspm_packages/
web_modules/
*.tsbuildinfo
.npm
.eslintcache
.stylelintcache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.node_repl_history
*.tgz
.yarn-integrity
# Environment
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.cache
.parcel-cache
.next
out
.nuxt
dist
.cache/
.vuepress/dist
.temp
.docusaurus
.serverless/
.fusebox/
.dynamodb/
.tern-port
.vscode-test
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.env*.local
# OS / Editor
.DS_Store
[._]*.s[a-v][a-z]
!*.svg # comment out if you don't need vector files
!*.svg
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
*~
[._]*.un~
# Vim
Session.vim
Sessionx.vim
.netrwhist
*~
tags
[._]*.un~
# JetBrains (PHPStorm)
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
.idea/**/aws.xml
.idea/**/contentModel.xml
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
@@ -92,44 +51,13 @@ tags
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
.idea/**/gradle.xml
.idea/**/libraries
cmake-build-*/
.idea/**/mongoSettings.xml
*.iws
out/
.idea_modules/
atlassian-ide-plugin.xml
.idea/replstate.xml
.idea/sonarlint/
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
.idea/httpRequests
.idea/caches/build_file_checksums.ser
npm-debug.log
yarn-error.log
bootstrap/compiled.php
app/storage/
public/storage
public/hot
public_html/storage
public_html/hot
storage/*.key
Homestead.yaml
Homestead.json
/.vagrant
/node_modules
/.pnp
.pnp.js
/coverage
/.next/
/out/
/build
.DS_Store
*.pem
.env*.local
.vercel
next-env.d.ts
# Claude Code
.claude/settings.local.json

4
.markdownlint.json Normal file
View File

@@ -0,0 +1,4 @@
{
"MD013": false,
"MD041": false
}

1
.markdownlintignore Normal file
View File

@@ -0,0 +1 @@
docs.md

26
.mega-linter.yml Normal file
View File

@@ -0,0 +1,26 @@
---
APPLY_FIXES: all
PARALLEL: true
VALIDATE_ALL_CODEBASE: true
GITHUB_STATUS_REPORTER: true
SARIF_REPORTER: true
IGNORE_GENERATED_FILES: true
PRINT_ALPACA: false
SHOW_SKIPPED_LINTERS: false
SHOW_ELAPSED_TIME: false
FILEIO_REPORTER: false
ENABLE_LINTERS:
- YAML_YAMLLINT
- MARKDOWN_MARKDOWNLINT
- PHP_PHPCS
DISABLE_LINTERS:
- REPOSITORY_DEVSKIM
- JSON_PRETTIER
- YAML_PRETTIER
PHP_PHPCS_CLI_LINT_MODE: project
PHP_PHPCS_ARGUMENTS: "--warning-severity=0"
FILTER_REGEX_EXCLUDE: "(vendor/|node_modules/|\\.git/|\\.claude/|composer\\.lock|package-lock\\.json)"

20
.yamllint.yml Normal file
View File

@@ -0,0 +1,20 @@
---
extends: default
rules:
comments:
require-starting-space: true
ignore-shebangs: true
min-spaces-from-content: 1
line-length:
max: 120
allow-non-breakable-words: true
allow-non-breakable-inline-mappings: false
truthy:
check-keys: false
brackets:
min-spaces-inside: 0
max-spaces-inside: 1
ignore: |
vendor/

40
CHANGELOG.md Normal file
View File

@@ -0,0 +1,40 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- v3 API client (`Ivuorinen\BusinessDataFetcher\v3\Client`) with support for:
- Company search (`searchCompanies()`)
- Code list descriptions (`getDescription()`)
- Postal code lookup (`getPostCodes()`)
- Full company ZIP download (`getAllCompanies()`)
- v3 DTOs based on the official OpenAPI schema
- Pest test suite with 57 tests covering traits, DTOs, and both API clients
- Shared HTTP infrastructure (`AbstractClient`, `HttpClientFactory`)
- PHP 8.4 added to CI matrix
- CI pipeline now runs linting, static analysis, and tests
- `phpunit.xml` configuration
- `.github/labels.yml` for label sync workflow
### Changed
- **BREAKING:** Replaced `spatie/data-transfer-object` with `cuyz/valinor` for DTO hydration
- **BREAKING:** All v1 DTOs are now `final readonly` classes with constructor promotion
- **BREAKING:** Removed `$sourceText` and `$authorityText` computed properties from traits; use `getSourceText()` and `getAuthorityString()` methods instead
- **BREAKING:** `BusinessDataFetcher` constructor now accepts an optional `?Client` parameter for testability
- Upgraded `squizlabs/php_codesniffer` from v3 to v4
- **BREAKING:** `BusinessDataFetcher` now extends `AbstractClient`
- **BREAKING:** Traits are now method-only (no properties or constructors)
- **BREAKING:** Removed empty `HasVersion` trait (version is a regular constructor property)
### Removed
- `spatie/data-transfer-object` dependency
- `ivuorinen/markdowndocs` dependency (incompatible with Pest's symfony/console requirement)
- `composer docs` script (pending markdowndocs update)

45
CLAUDE.md Normal file
View File

@@ -0,0 +1,45 @@
# CLAUDE.md
## Project Overview
PHP library for fetching Finnish business data from the PRH (Finnish Patent and Registration Office) open data API. Supports both v1 (BIS) and v3 (YTJ) APIs.
## Commands
```bash
composer lint # PSR-12 linting (phpcs v4) + Rector dry-run
composer lint-fix # Auto-fix PSR-12 violations (phpcbf) + Rector apply
composer rector # Run Rector standalone
composer phpstan # Static analysis (level 9)
composer test # Pest test suite
```
## Architecture
- **v1 Entry point**: `src/BusinessDataFetcher.php` — extends `AbstractClient`, fetches from `/bis/v1`
- **v3 Entry point**: `src/v3/Client.php` — extends `AbstractClient`, fetches from `/opendata-ytj-api/v3`
- **Shared HTTP**: `src/Http/AbstractClient.php` — base class with Guzzle + Valinor mapper
- **v1 DTOs**: `src/v1/Dto/``final readonly` classes with Valinor-compatible constructors
- **v3 DTOs**: `src/v3/Dto/``final readonly` classes matching the v3 OpenAPI schema
- **Traits**: `src/v1/Traits/` — method-only helpers (`HasSource`, `HasLanguage`, `HasRegister`, `HasAuthority`, `HasChange`)
- **Exceptions**: `src/v1/Exceptions/`, `src/v3/Exceptions/`
- **Tests**: `tests/Unit/` — Pest tests for traits, DTOs, and both clients
## Conventions
- PHP 8.2+
- PSR-12 coding standard (squizlabs/php_codesniffer v4)
- Rector (`rector.php`): `deadCode`, `codeQuality`, `typeDeclarations` sets — no `codingStyle` (phpcs handles that)
- PHPStan level 9
- Valinor v1 for DTO hydration with `allowSuperfluousKeys()`
- All DTOs are `final readonly` with constructor promotion
- Traits are method-only (no properties)
- Namespace: `Ivuorinen\BusinessDataFetcher`
## CI/CD
- GitHub Actions workflows in `.github/workflows/`
- Reusable composite actions from `ivuorinen/actions` (not `ivuorinen/.github`)
- Renovate config: `.github/renovate.json` extends `github>ivuorinen/renovate-config`
- `pinact run -u -v --fix` — pin all action refs to commit SHAs after editing workflows
- PHP matrix: 8.2, 8.3, 8.4

47
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,47 @@
# Contributing
## Requirements
- PHP 8.2+
- Composer
## Setup
```bash
git clone https://github.com/ivuorinen/business-data-fetcher.git
cd business-data-fetcher
composer install
```
## Development Workflow
### Running checks
```bash
composer lint # PSR-12 linting
composer lint-fix # Auto-fix PSR-12 violations
composer phpstan # Static analysis (level 9)
composer test # Pest test suite
```
### Branch Strategy
- `main` — stable release branch
- `feat/*` — feature branches
- `fix/*` — bugfix branches
### Pull Request Process
1. Create a feature or fix branch from `main`
2. Make your changes
3. Ensure all checks pass: `composer lint && composer phpstan && composer test`
4. Open a PR against `main`
5. PRs require passing CI before merge
## Code Conventions
- PSR-12 coding standard
- PHPStan level 9 strict analysis
- All DTOs are `final readonly` classes with constructor promotion
- Use Valinor for hydration (not manual array mapping)
- Write Pest tests for new functionality

111
README.md
View File

@@ -1,36 +1,112 @@
# Business Data Fetcher
This is an API client to Finnish Patent and Registration
Office's (PRH) Business Information System (BIS) v1.
[![PHP Composer](https://github.com/ivuorinen/business-data-fetcher/actions/workflows/composer.yml/badge.svg)](https://github.com/ivuorinen/business-data-fetcher/actions/workflows/composer.yml)
Use it to get company data from the Business Information System by Business ID.
PHP library for fetching Finnish business data from the PRH (Finnish Patent and Registration Office) open data API.
Supports both **v1** (BIS) and **v3** (YTJ) APIs.
## Installation
```bash
composer install ivuorinen/business-data-fetcher
composer require ivuorinen/business-data-fetcher
```
## Usage example
## Usage
### v1 API (BIS)
Fetch company details by Business ID:
```php
<?php
require_once 'vendor/autoload.php';
use Ivuorinen\BusinessDataFetcher\BusinessDataFetcher;
$client = new Ivuorinen\BusinessDataFetcher\BusinessDataFetcher();
try {
$results = $client->getBusinessInformation('1639413-9');
print_r($results);
} catch (\GuzzleHttp\Exception\GuzzleException $e) {
var_dump($e);
$client = new BusinessDataFetcher();
$results = $client->getBusinessInformation('1639413-9');
foreach ($results as $company) {
echo $company->name . "\n";
echo $company->businessId . "\n";
foreach ($company->names as $name) {
echo $name->name . ' (' . $name->getLanguageString() . ")\n";
}
}
```
## Data source
### v3 API (YTJ)
All models are transcribed from PRH Open Data portal. You can find the examples
and models descriptions, among other details and live API query tool following
this link: https://avoindata.prh.fi/ytj_en.html
Search companies with multiple filters:
```php
use Ivuorinen\BusinessDataFetcher\v3\Client;
$client = new Client();
// Search by name
$result = $client->searchCompanies(name: 'Example');
echo "Found {$result->totalResults} companies\n";
foreach ($result->companies as $company) {
echo $company->businessId->value . ' - ';
echo $company->names[0]->name . "\n";
}
// Search by Business ID
$result = $client->searchCompanies(businessId: '1639413-9');
// Search with multiple filters
$result = $client->searchCompanies(
location: 'Helsinki',
companyForm: 'OY',
mainBusinessLine: '62010',
);
// Get code list descriptions
$description = $client->getDescription('REK', 'en');
// Get postal codes
$postCodes = $client->getPostCodes('fi');
// Download all companies as ZIP
$stream = $client->getAllCompanies();
file_put_contents('companies.zip', $stream->getContents());
```
## v1 vs v3 API Comparison
| Feature | v1 (BIS) | v3 (YTJ) |
| --------- | ---------- | ---------- |
| Base URL | `/bis/v1` | `/opendata-ytj-api/v3` |
| Lookup | By Business ID only | Search by name, location, form, etc. |
| Company form | String code | Structured with descriptions |
| Addresses | Flat structure | Nested with PostOffice objects |
| Code lists | N/A | `/description` endpoint |
| Postal codes | N/A | `/post_codes` endpoint |
| Bulk download | N/A | `/all_companies` ZIP |
## Migration from v1 DTOs
If upgrading from the Spatie DTO version:
- DTOs are now `final readonly` classes (no `->toArray()`)
- `$sourceText` / `$authorityText` properties removed; use `getSourceText()` / `getAuthorityString()` methods
- `HasVersion` trait removed; `$version` is a regular constructor property
- `BusinessDataFetcher` constructor accepts an optional Guzzle `Client` for testing
## Development
```bash
composer lint # PSR-12 linting
composer lint-fix # Auto-fix violations
composer phpstan # Static analysis (level 9)
composer test # Pest test suite
```
## Data Source
- v1: <https://avoindata.prh.fi/ytj_en.html>
- v3: <https://avoindata.prh.fi/fi/ytj/swagger-ui>
## Notice of Liability
@@ -40,4 +116,3 @@ of this library are providing this without compensation and cannot be held respo
## License
[MIT licensed](LICENSE.md)

69
captainhook.json Normal file
View File

@@ -0,0 +1,69 @@
{
"pre-commit": {
"enabled": true,
"actions": [
{
"action": "\\CaptainHook\\App\\Hook\\Diff\\Action\\BlockSecrets",
"options": {},
"conditions": []
},
{
"action": "composer lint-fix",
"options": {},
"conditions": []
},
{
"action": "composer test",
"options": {},
"conditions": []
}
]
},
"commit-msg": {
"enabled": true,
"actions": [
{
"action": "\\Ramsey\\CaptainHook\\ValidateConventionalCommit",
"options": {},
"conditions": []
}
]
},
"pre-push": {
"enabled": false,
"actions": []
},
"post-commit": {
"enabled": false,
"actions": []
},
"post-merge": {
"enabled": false,
"actions": []
},
"post-checkout": {
"enabled": false,
"actions": []
},
"post-rewrite": {
"enabled": false,
"actions": []
},
"post-change": {
"enabled": true,
"actions": [
{
"action": "composer install",
"options": {},
"conditions": [
{
"exec": "CaptainHook.FileChanged.Any",
"args": [
["composer.json", "composer.lock"]
]
}
]
}
]
}
}

View File

@@ -5,23 +5,51 @@
"license": "MIT",
"require": {
"php": "^8.2",
"ext-json": "*",
"guzzlehttp/guzzle": "^7.4",
"spatie/data-transfer-object": "^3.9"
"cuyz/valinor": "^1.0"
},
"autoload": {
"psr-4": {
"Ivuorinen\\BusinessDataFetcher\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Ivuorinen\\BusinessDataFetcher\\Tests\\": "tests/"
}
},
"require-dev": {
"squizlabs/php_codesniffer": "^4.0",
"ivuorinen/markdowndocs": "^4.0",
"phpstan/phpstan": "^2.0"
"phpstan/phpstan": "^2.0",
"pestphp/pest": "^3.0",
"rector/rector": "^2.3",
"captainhook/captainhook": "^5.0",
"captainhook/hook-installer": "^1.0",
"ramsey/conventional-commits": "^1.7",
"captainhook/secrets": "^0.9.7",
"phpstan/extension-installer": "^1.0",
"jbelien/phpstan-sarif-formatter": "^1.0",
"bartlett/sarif-php-converters": "^1.0"
},
"scripts": {
"docs": "php vendor/bin/phpdoc-md generate src > docs.md",
"lint": "php vendor/bin/phpcs --standard=PSR12 src",
"lint-fix": "php vendor/bin/phpcbf --standard=PSR12 src",
"phpstan": "php vendor/bin/phpstan analyse"
"lint": [
"php vendor/bin/phpcs --standard=PSR12 src",
"php vendor/bin/rector --dry-run"
],
"lint-fix": [
"php vendor/bin/phpcbf --standard=PSR12 src",
"php vendor/bin/rector"
],
"rector": "php vendor/bin/rector",
"phpstan": "php vendor/bin/phpstan analyse",
"test": "php vendor/bin/pest"
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true,
"captainhook/hook-installer": true,
"phpstan/extension-installer": true
}
}
}

4580
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,8 @@ require_once "vendor/autoload.php";
$client = new Ivuorinen\BusinessDataFetcher\BusinessDataFetcher();
try {
$results = $client->getBusinessInformation("1639413-9");
print_r($results);
// Convert to JSON
echo json_encode($results, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
} catch (Exception $e) {
var_dump($e);
}

View File

@@ -87,7 +87,7 @@ foreach ($classes as $className => $vars) {
// Get name of the class from filename and split CamelCase to words.
$classNameString = $className;
$classNameString = str_replace("Bis", "", $classNameString);
$classNameString = preg_replace('/(?<!^)[A-Z]/', ' $0', $classNameString);
$classNameString = (string)preg_replace('/(?<!^)[A-Z]/', ' $0', $classNameString);
$classNameString = ucwords($classNameString);
$usesHeader = [
@@ -125,7 +125,7 @@ foreach ($classes as $className => $vars) {
}
if (!empty($traits)) {
$usesHeader[] = "use Ivuorinen\BusinessDataFetcher\Traits;";
$usesHeader[] = "use Ivuorinen\BusinessDataFetcher\\v1\Traits;";
}
$usesString = implode("\n", $usesHeader);
@@ -134,7 +134,7 @@ foreach ($classes as $className => $vars) {
$file = "<?php
namespace Ivuorinen\BusinessDataFetcher\Dto;
namespace Ivuorinen\BusinessDataFetcher\\v1\Dto;
$usesString
/**
@@ -181,7 +181,9 @@ class $className extends DataTransferObject
if (!empty($files)) {
echo "Generating files:\n";
$dtoDir = sprintf('%s%s%s%s%s', dirname(__FILE__, 2), DS, 'src', DS, 'Dto');
// Set the directory for the DTO classes.
$dtoDir = implode(DS, [dirname(__FILE__, 2), 'src', 'v1', 'Dto']);
foreach ($files as $className => $file) {
$filePath = sprintf('%s%s%s.php', $dtoDir, DS, $className);
echo $filePath . "\n";

View File

@@ -1,6 +1,7 @@
<?xml version="1.0"?>
<ruleset name="PHP_CodeSniffer">
<description>PSR12</description>
<file>src</file>
<rule ref="PSR12">
<exclude name="Generic.WhiteSpace.DisallowTabIndent"/>
<exclude name="PSR12.Operators.OperatorSpacing"/>

18
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

16
rector.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/src',
])
->withPhpSets(php82: true)
->withPreparedSets(
deadCode: true,
codeQuality: true,
typeDeclarations: true,
);

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
{
}

View File

@@ -0,0 +1,112 @@
{
"type": "fi.prh.opendata.bis",
"version": "1",
"totalResults": -1,
"resultsFrom": 0,
"previousResultsUri": null,
"nextResultsUri": null,
"exceptionNoticeUri": null,
"results": [
{
"businessId": "1639413-9",
"registrationDate": "2001-04-03",
"companyForm": "OY",
"detailsUri": null,
"name": "Oy Example Ab",
"names": [
{
"order": 0,
"version": 1,
"registrationDate": "2015-01-19",
"endDate": null,
"name": "Oy Example Ab",
"source": 0,
"language": "fi"
}
],
"auxiliaryNames": [],
"addresses": [
{
"careOf": null,
"street": "Testitie 1",
"postCode": "00100",
"type": 1,
"city": "HELSINKI",
"country": "FI",
"source": 0,
"version": 1,
"registrationDate": "2019-04-30",
"endDate": null,
"language": "fi"
}
],
"companyForms": [
{
"version": 1,
"registrationDate": "2001-04-03",
"endDate": null,
"name": "Osakeyhtiö",
"type": "OY",
"source": 0,
"language": "fi"
}
],
"liquidations": [],
"businessLines": [
{
"order": 0,
"version": 1,
"registrationDate": "2017-12-31",
"endDate": null,
"name": "Computer programming activities",
"source": 2,
"language": "en"
}
],
"languages": [
{
"version": 1,
"registrationDate": "2001-04-03",
"endDate": null,
"name": "Finnish",
"source": 0,
"language": "en"
}
],
"registeredOffices": [
{
"order": 0,
"version": 1,
"registrationDate": "2001-04-03",
"endDate": null,
"name": "HELSINKI",
"source": 0,
"language": "fi"
}
],
"contactDetails": [
{
"version": 1,
"registrationDate": "2019-04-30",
"endDate": null,
"language": "fi",
"value": "www.example.fi",
"type": "Website",
"source": 0
}
],
"registeredEntries": [
{
"authority": 2,
"register": 1,
"status": 2,
"registrationDate": "2001-06-01",
"endDate": null,
"description": "Registered",
"language": "en"
}
],
"businessIdChanges": []
}
]
}

View File

@@ -0,0 +1,101 @@
{
"totalResults": 1,
"companies": [
{
"businessId": {
"value": "1639413-9",
"registrationDate": "2001-04-03",
"source": "YTJ"
},
"euId": {
"value": "FIFOO123456",
"source": "YTJ"
},
"names": [
{
"name": "Oy Example Ab",
"type": "TOIM",
"registrationDate": "2015-01-19",
"endDate": null,
"version": 1,
"source": "YTJ"
}
],
"mainBusinessLine": {
"code": "62010",
"descriptions": [
{
"languageCode": "3",
"description": "Computer programming activities"
}
],
"typeCodeSet": "TOL2008",
"registrationDate": "2017-12-31",
"source": "VERO"
},
"website": {
"url": "www.example.fi",
"registrationDate": "2019-04-30",
"source": "YTJ"
},
"companyForms": [
{
"type": "OY",
"descriptions": [
{
"languageCode": "1",
"description": "Osakeyhtiö"
},
{
"languageCode": "3",
"description": "Limited company"
}
],
"registrationDate": "2001-04-03",
"endDate": null,
"version": 1,
"source": "YTJ"
}
],
"companySituations": [],
"registeredEntries": [
{
"type": "REK",
"descriptions": [
{
"languageCode": "3",
"description": "Registered"
}
],
"registrationDate": "2001-06-01",
"endDate": null,
"register": "1",
"authority": "2"
}
],
"addresses": [
{
"type": 1,
"street": "Testitie 1",
"postCode": "00100",
"postOffices": [
{
"city": "HELSINKI",
"languageCode": "1",
"municipalityCode": "091"
}
],
"co": null,
"country": "FI",
"registrationDate": "2019-04-30",
"source": "YTJ"
}
],
"tradeRegisterStatus": "2",
"status": "ACTIVE",
"registrationDate": "2001-04-03",
"endDate": null,
"lastModified": "2023-06-15T10:30:00Z"
}
]
}

9
tests/Pest.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
*/
// uses(Tests\TestCase::class)->in('Feature');

View File

@@ -0,0 +1,52 @@
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Ivuorinen\BusinessDataFetcher\BusinessDataFetcher;
use Ivuorinen\BusinessDataFetcher\v1\Dto\BisCompanyDetails;
use Ivuorinen\BusinessDataFetcher\v1\Exceptions\ApiResponseErrorException;
function createFetcherWithMock(Response ...$responses): BusinessDataFetcher
{
$mock = new MockHandler($responses);
$client = new Client(['handler' => HandlerStack::create($mock)]);
return new BusinessDataFetcher($client);
}
it('returns BisCompanyDetails array on success', function () {
$json = file_get_contents(__DIR__ . '/../Fixtures/sample-response.json');
$fetcher = createFetcherWithMock(new Response(200, [], $json));
$results = $fetcher->getBusinessInformation('1639413-9');
expect($results)->toBeArray()
->and($results)->toHaveCount(1)
->and($results[0])->toBeInstanceOf(BisCompanyDetails::class)
->and($results[0]->businessId)->toBe('1639413-9');
});
it('throws on non-200 status code', function () {
$fetcher = createFetcherWithMock(
new Response(500, [], '{"error": "Internal Server Error"}')
);
$fetcher->getBusinessInformation('1639413-9');
})->throws(ApiResponseErrorException::class);
it('throws on malformed JSON', function () {
$fetcher = createFetcherWithMock(
new Response(200, [], 'not-json')
);
$fetcher->getBusinessInformation('1639413-9');
})->throws(JsonException::class);
it('throws when results key is missing', function () {
$fetcher = createFetcherWithMock(
new Response(200, [], '{"type": "test"}')
);
$fetcher->getBusinessInformation('1639413-9');
})->throws(ApiResponseErrorException::class);

View File

@@ -0,0 +1,43 @@
<?php
use CuyZ\Valinor\MapperBuilder;
use Ivuorinen\BusinessDataFetcher\v1\Dto\BisCompanyDetails;
it('hydrates from sample JSON fixture', function () {
$json = json_decode(
file_get_contents(__DIR__ . '/../../../Fixtures/sample-response.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$mapper = (new MapperBuilder())
->allowSuperfluousKeys()
->mapper();
$result = $mapper->map(BisCompanyDetails::class, $json['results'][0]);
expect($result)
->toBeInstanceOf(BisCompanyDetails::class)
->and($result->businessId)->toBe('1639413-9')
->and($result->registrationDate)->toBe('2001-04-03')
->and($result->companyForm)->toBe('OY')
->and($result->name)->toBe('Oy Example Ab')
->and($result->names)->toHaveCount(1)
->and($result->names[0]->name)->toBe('Oy Example Ab')
->and($result->names[0]->getLanguageString())->toBe('finnish')
->and($result->names[0]->getSourceText())->toBe('common')
->and($result->addresses)->toHaveCount(1)
->and($result->addresses[0]->street)->toBe('Testitie 1')
->and($result->addresses[0]->postCode)->toBe('00100')
->and($result->addresses[0]->city)->toBe('HELSINKI')
->and($result->companyForms)->toHaveCount(1)
->and($result->companyForms[0]->name)->toBe('Osakeyhtiö')
->and($result->businessLines)->toHaveCount(1)
->and($result->businessLines[0]->name)->toBe('Computer programming activities')
->and($result->registeredEntries)->toHaveCount(1)
->and($result->registeredEntries[0]->getAuthorityString())->toBe('Finnish Patent and Registration Office')
->and($result->registeredEntries[0]->getRegisterString())->toBe('Trade Register')
->and($result->contactDetails)->toHaveCount(1)
->and($result->contactDetails[0]->value)->toBe('www.example.fi');
});

View File

@@ -0,0 +1,87 @@
<?php
use CuyZ\Valinor\MapperBuilder;
use Ivuorinen\BusinessDataFetcher\v1\Dto\BisAddress;
use Ivuorinen\BusinessDataFetcher\v1\Dto\BisCompanyBusinessIdChange;
use Ivuorinen\BusinessDataFetcher\v1\Dto\BisCompanyBusinessLine;
use Ivuorinen\BusinessDataFetcher\v1\Dto\BisCompanyContactDetail;
use Ivuorinen\BusinessDataFetcher\v1\Dto\BisCompanyForm;
use Ivuorinen\BusinessDataFetcher\v1\Dto\BisCompanyLanguage;
use Ivuorinen\BusinessDataFetcher\v1\Dto\BisCompanyLiquidation;
use Ivuorinen\BusinessDataFetcher\v1\Dto\BisCompanyName;
use Ivuorinen\BusinessDataFetcher\v1\Dto\BisCompanyRegisteredEntry;
use Ivuorinen\BusinessDataFetcher\v1\Dto\BisCompanyRegisteredOffice;
function valinorMapper(): CuyZ\Valinor\Mapper\TreeMapper
{
return (new MapperBuilder())
->allowSuperfluousKeys()
->mapper();
}
it('hydrates all DTOs from arrays', function (string $class, array $data, string $property, mixed $expected) {
$dto = valinorMapper()->map($class, $data);
expect($dto)->toBeInstanceOf($class)
->and($dto->$property)->toBe($expected);
})->with([
'BisAddress' => [
BisAddress::class,
['type' => 1, 'street' => 'Katu 1', 'postCode' => '00100', 'city' => 'HELSINKI', 'source' => 0, 'version' => 1, 'language' => 'fi', 'registrationDate' => '2020-01-01'],
'street',
'Katu 1',
],
'BisCompanyName' => [
BisCompanyName::class,
['order' => 0, 'name' => 'Test Oy', 'source' => 1, 'version' => 1, 'language' => 'fi', 'registrationDate' => '2020-01-01'],
'name',
'Test Oy',
],
'BisCompanyForm' => [
BisCompanyForm::class,
['name' => 'Osakeyhtiö', 'type' => 'OY', 'source' => 0, 'version' => 1, 'language' => 'fi', 'registrationDate' => '2020-01-01'],
'type',
'OY',
],
'BisCompanyBusinessLine' => [
BisCompanyBusinessLine::class,
['order' => 0, 'name' => 'IT', 'source' => 2, 'version' => 1, 'language' => 'en', 'registrationDate' => '2020-01-01'],
'name',
'IT',
],
'BisCompanyLanguage' => [
BisCompanyLanguage::class,
['name' => 'Finnish', 'source' => 0, 'version' => 1, 'language' => 'fi', 'registrationDate' => '2020-01-01'],
'language',
'fi',
],
'BisCompanyRegisteredEntry' => [
BisCompanyRegisteredEntry::class,
['authority' => 2, 'register' => 1, 'status' => 2, 'description' => 'Registered', 'language' => 'en', 'registrationDate' => '2020-01-01'],
'description',
'Registered',
],
'BisCompanyLiquidation' => [
BisCompanyLiquidation::class,
['name' => 'Bankruptcy', 'type' => 'K', 'source' => 0, 'version' => 1, 'language' => 'fi', 'registrationDate' => '2020-01-01'],
'name',
'Bankruptcy',
],
'BisCompanyRegisteredOffice' => [
BisCompanyRegisteredOffice::class,
['order' => 0, 'name' => 'Helsinki', 'source' => 0, 'version' => 1, 'language' => 'fi', 'registrationDate' => '2020-01-01'],
'name',
'Helsinki',
],
'BisCompanyContactDetail' => [
BisCompanyContactDetail::class,
['value' => 'www.test.fi', 'type' => 'Website', 'source' => 0, 'version' => 1, 'language' => 'fi', 'registrationDate' => '2020-01-01'],
'value',
'www.test.fi',
],
'BisCompanyBusinessIdChange' => [
BisCompanyBusinessIdChange::class,
['description' => 'Changed', 'reason' => '1', 'oldBusinessId' => '1234567-8', 'newBusinessId' => '8765432-1', 'source' => 0, 'language' => 'fi', 'change' => 5],
'oldBusinessId',
'1234567-8',
],
]);

View File

@@ -0,0 +1,30 @@
<?php
use Ivuorinen\BusinessDataFetcher\v1\Traits\HasAuthority;
function makeAuthorityObject(int $authority): object
{
return new class ($authority) {
use HasAuthority;
public function __construct(public int $authority)
{
}
};
}
it('returns Tax Administration for authority 1', function () {
expect(makeAuthorityObject(1)->getAuthorityString())->toBe('Tax Administration');
});
it('returns Finnish Patent and Registration Office for authority 2', function () {
expect(makeAuthorityObject(2)->getAuthorityString())->toBe('Finnish Patent and Registration Office');
});
it('returns Population Register for authority 3', function () {
expect(makeAuthorityObject(3)->getAuthorityString())->toBe('Population Register');
});
it('returns unknown for unknown authority', function () {
expect(makeAuthorityObject(99)->getAuthorityString())->toBe('unknown:99');
});

View File

@@ -0,0 +1,41 @@
<?php
use Ivuorinen\BusinessDataFetcher\v1\Traits\HasChange;
function makeChangeObject(string|int|null $change): object
{
return new class ($change) {
use HasChange;
public function __construct(public string|int|null $change)
{
}
};
}
it('maps all integer change codes correctly', function (int $code, string $expected) {
expect(makeChangeObject($code)->getChangeString())->toBe($expected);
})->with([
[2, 'Business ID removal'],
[3, 'Combining of double IDs'],
[5, 'ID changed'],
[44, 'Fusion'],
[45, 'Operator continuing VAT activities'],
[46, 'Relation to predecessor'],
[47, 'Division'],
[48, 'Bankruptcy relationship'],
[49, 'Operations continued by a private trader'],
[57, 'Partial division'],
]);
it('maps FUU string to Fusion', function () {
expect(makeChangeObject('FUU')->getChangeString())->toBe('Fusion');
});
it('maps DIF string to Division', function () {
expect(makeChangeObject('DIF')->getChangeString())->toBe('Division');
});
it('returns unknown for unrecognized change', function () {
expect(makeChangeObject(999)->getChangeString())->toBe('unknown:999');
});

View File

@@ -0,0 +1,30 @@
<?php
use Ivuorinen\BusinessDataFetcher\v1\Traits\HasLanguage;
function makeLanguageObject(?string $language): object
{
return new class ($language) {
use HasLanguage;
public function __construct(public ?string $language)
{
}
};
}
it('returns finnish for fi', function () {
expect(makeLanguageObject('fi')->getLanguageString())->toBe('finnish');
});
it('returns english for en', function () {
expect(makeLanguageObject('en')->getLanguageString())->toBe('english');
});
it('returns swedish for sv', function () {
expect(makeLanguageObject('sv')->getLanguageString())->toBe('swedish');
});
it('returns unknown for unrecognized language', function () {
expect(makeLanguageObject('de')->getLanguageString())->toBe('unknown:de');
});

View File

@@ -0,0 +1,35 @@
<?php
use Ivuorinen\BusinessDataFetcher\v1\Traits\HasRegister;
function makeRegisterObject(?int $register): object
{
return new class ($register) {
use HasRegister;
public function __construct(public ?int $register)
{
}
};
}
it('maps all register codes correctly', function (int $code, string $expected) {
expect(makeRegisterObject($code)->getRegisterString())->toBe($expected);
})->with([
[1, 'Trade Register'],
[2, 'Register of Foundations'],
[3, 'Register of Associations'],
[4, 'Tax Administration'],
[5, 'Prepayment Register'],
[6, 'VAT Register'],
[7, 'Employer Register'],
[8, 'Register of bodies liable for tax on insurance premiums'],
]);
it('returns unknown for unrecognized register', function () {
expect(makeRegisterObject(99)->getRegisterString())->toBe('unknown:99');
});
it('returns unknown for null register', function () {
expect(makeRegisterObject(null)->getRegisterString())->toBe('unknown:');
});

View File

@@ -0,0 +1,34 @@
<?php
use Ivuorinen\BusinessDataFetcher\v1\Traits\HasSource;
function makeSourceObject(?int $source): object
{
return new class ($source) {
use HasSource;
public function __construct(public ?int $source)
{
}
};
}
it('returns common for source 0', function () {
expect(makeSourceObject(0)->getSourceText())->toBe('common');
});
it('returns Finnish Patent and Registration Office for source 1', function () {
expect(makeSourceObject(1)->getSourceText())->toBe('Finnish Patent and Registration Office');
});
it('returns Tax Administration for source 2', function () {
expect(makeSourceObject(2)->getSourceText())->toBe('Tax Administration');
});
it('returns Business Information System for source 3', function () {
expect(makeSourceObject(3)->getSourceText())->toBe('Business Information System');
});
it('returns empty string for unknown source', function () {
expect(makeSourceObject(99)->getSourceText())->toBe('');
});

View File

@@ -0,0 +1,73 @@
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Ivuorinen\BusinessDataFetcher\v3\Client as V3Client;
use Ivuorinen\BusinessDataFetcher\v3\Dto\CompanySearchResult;
use Ivuorinen\BusinessDataFetcher\v3\Dto\PostCodeEntry;
use Ivuorinen\BusinessDataFetcher\v3\Exceptions\V3ApiException;
function createV3ClientWithMock(Response ...$responses): V3Client
{
$mock = new MockHandler($responses);
$client = new Client(['handler' => HandlerStack::create($mock)]);
return new V3Client($client);
}
it('searches companies successfully', function () {
$json = file_get_contents(__DIR__ . '/../../Fixtures/v3-search-response.json');
$client = createV3ClientWithMock(new Response(200, [], $json));
$result = $client->searchCompanies(name: 'Example');
expect($result)
->toBeInstanceOf(CompanySearchResult::class)
->and($result->totalResults)->toBe(1)
->and($result->companies)->toHaveCount(1)
->and($result->companies[0]->businessId->value)->toBe('1639413-9')
->and($result->companies[0]->names[0]->name)->toBe('Oy Example Ab')
->and($result->companies[0]->tradeRegisterStatus)->toBe('2')
->and($result->companies[0]->addresses[0]->street)->toBe('Testitie 1');
});
it('throws V3ApiException on request failure for search', function () {
$client = createV3ClientWithMock(new Response(500, [], '{"message":"error"}'));
$client->searchCompanies(name: 'Test');
})->throws(V3ApiException::class);
it('retrieves description as plain text', function () {
$client = createV3ClientWithMock(new Response(200, [], 'Trade Register'));
$result = $client->getDescription('REK', 'en');
expect($result)->toBe('Trade Register');
});
it('throws V3ApiException on description failure', function () {
$client = createV3ClientWithMock(new Response(400, [], '{"message":"bad request"}'));
$client->getDescription('INVALID');
})->throws(V3ApiException::class);
it('retrieves post codes', function () {
$json = (string) json_encode([
['postCode' => '00100', 'city' => 'HELSINKI', 'active' => true, 'languageCode' => '1', 'municipalityCode' => '091'],
['postCode' => '00200', 'city' => 'HELSINKI', 'active' => true, 'languageCode' => '1', 'municipalityCode' => '091'],
]);
$client = createV3ClientWithMock(new Response(200, [], $json));
$results = $client->getPostCodes('fi');
expect($results)->toHaveCount(2)
->and($results[0])->toBeInstanceOf(PostCodeEntry::class)
->and($results[0]->postCode)->toBe('00100')
->and($results[0]->city)->toBe('HELSINKI')
->and($results[1]->postCode)->toBe('00200');
});
it('returns stream for all companies download', function () {
$client = createV3ClientWithMock(new Response(200, [], 'zip-content'));
$stream = $client->getAllCompanies();
expect($stream->getContents())->toBe('zip-content');
});

View File

@@ -0,0 +1,53 @@
<?php
use CuyZ\Valinor\MapperBuilder;
use Ivuorinen\BusinessDataFetcher\v3\Dto\Company;
use Ivuorinen\BusinessDataFetcher\v3\Dto\CompanySearchResult;
it('hydrates Company from v3 search response fixture', function () {
$json = json_decode(
file_get_contents(__DIR__ . '/../../../Fixtures/v3-search-response.json'),
true,
512,
JSON_THROW_ON_ERROR
);
$mapper = (new MapperBuilder())
->allowSuperfluousKeys()
->mapper();
$result = $mapper->map(CompanySearchResult::class, $json);
expect($result->totalResults)->toBe(1)
->and($result->companies)->toHaveCount(1);
$company = $result->companies[0];
expect($company)->toBeInstanceOf(Company::class)
->and($company->businessId->value)->toBe('1639413-9')
->and($company->businessId->registrationDate)->toBe('2001-04-03')
->and($company->euId)->not->toBeNull()
->and($company->euId->value)->toBe('FIFOO123456')
->and($company->names)->toHaveCount(1)
->and($company->names[0]->name)->toBe('Oy Example Ab')
->and($company->names[0]->type)->toBe('TOIM')
->and($company->mainBusinessLine)->not->toBeNull()
->and($company->mainBusinessLine->code)->toBe('62010')
->and($company->mainBusinessLine->descriptions)->toHaveCount(1)
->and($company->mainBusinessLine->descriptions[0]->description)->toBe('Computer programming activities')
->and($company->website)->not->toBeNull()
->and($company->website->url)->toBe('www.example.fi')
->and($company->companyForms)->toHaveCount(1)
->and($company->companyForms[0]->type)->toBe('OY')
->and($company->companyForms[0]->descriptions)->toHaveCount(2)
->and($company->registeredEntries)->toHaveCount(1)
->and($company->registeredEntries[0]->register)->toBe('1')
->and($company->registeredEntries[0]->authority)->toBe('2')
->and($company->addresses)->toHaveCount(1)
->and($company->addresses[0]->street)->toBe('Testitie 1')
->and($company->addresses[0]->postOffices)->toHaveCount(1)
->and($company->addresses[0]->postOffices[0]->city)->toBe('HELSINKI')
->and($company->tradeRegisterStatus)->toBe('2')
->and($company->status)->toBe('ACTIVE')
->and($company->lastModified)->toBe('2023-06-15T10:30:00Z');
});