From 3162c01f5aca2b630e6604f87e5774f39c82b1c0 Mon Sep 17 00:00:00 2001 From: Andrei Ghinea Date: Wed, 6 May 2026 11:25:33 +0300 Subject: [PATCH] Annotations and migration to PHP 8.1 minimum version --- .github/workflows/test.yaml | 56 ++++- CHANGELOG.md | 11 +- README.md | 28 ++- composer.json | 11 +- docs/cli-examples.md | 2 +- docs/programmatic_oauth_example.php | 2 + psalm-baseline.xml | 2 + psalm.xml | 54 +++++ src/ApiConnector.php | 47 ++-- src/CaseFile.php | 66 +++--- src/CopyRecipient.php | 4 +- src/Customer.php | 14 +- src/CustomerBranding.php | 20 +- src/Document.php | 155 +++++++++++-- src/Entity.php | 258 ++++++++++++++++----- src/Folder.php | 7 +- src/LogEntry.php | 2 +- src/Message.php | 4 +- src/MessageTemplate.php | 6 +- src/OAuth/ApiKeysMiddleware.php | 10 +- src/OAuth/OAuthApi.php | 25 +- src/OAuth/RefreshTokenMiddleware.php | 10 +- src/OAuth/Tokens/PenneoTokens.php | 5 +- src/OAuth/Tokens/PenneoTokensValidator.php | 4 +- src/OAuth/Tokens/SessionTokenStorage.php | 3 + src/SignatureLine.php | 24 +- src/Signer.php | 14 +- src/SigningRequest.php | 28 +-- src/Validation.php | 22 +- src/WebhookSubscription.php | 2 +- tests/unit/OAuth/OAuthApiTest.php | 3 + 31 files changed, 695 insertions(+), 204 deletions(-) create mode 100644 psalm-baseline.xml create mode 100644 psalm.xml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5fc8bf7..ed1c7d6 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -12,11 +12,63 @@ concurrency: cancel-in-progress: true jobs: + format: + runs-on: ubuntu-latest + name: Auto-fix check (PHPCBF) + steps: + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 #4.1.6 + + - name: Build PHP container + run: docker build --build-arg "PHP_VERSION=8.3" -t php --file docker/Dockerfile docker/ + + - name: Start PHP container + run: | + docker run -d --name php -v ${GITHUB_WORKSPACE}:/app php tail -f /dev/null + + - name: Install dependencies + run: docker exec php /bin/sh -c "composer install" + + - name: Apply PHPCBF (must leave tree clean) + run: docker exec php /bin/sh -c "bin/phpcbf" + + - name: Fail if formatting differed + run: git diff --exit-code + + - name: Stop PHP container + if: always() + run: docker stop php + + psalm: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ["8.1", "8.2", "8.3", "8.4", "8.5"] + name: Psalm (PHP ${{ matrix.php-version }}) + steps: + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 #4.1.6 + + - name: Build PHP container + run: docker build --build-arg "PHP_VERSION=${{ matrix.php-version }}" -t php --file docker/Dockerfile docker/ + + - name: Start PHP container + run: | + docker run -d --name php -v ${GITHUB_WORKSPACE}:/app php tail -f /dev/null + + - name: Install dependencies + run: docker exec php /bin/sh -c "composer install" + + - name: Run Psalm + run: docker exec php /bin/sh -c "composer psalm -- --php-version=${{ matrix.php-version }}" + + - name: Stop PHP container + if: always() + run: docker stop php + lint: runs-on: ubuntu-latest strategy: matrix: - php-version: ["7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"] + php-version: ["8.1", "8.2", "8.3", "8.4", "8.5"] name: Lint (PHP ${{ matrix.php-version }}) steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #4.1.1 @@ -42,7 +94,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php-version: ["7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"] + php-version: ["8.1", "8.2", "8.3", "8.4", "8.5"] name: PHP v${{ matrix.php-version }} steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 #4.1.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b8342f..85cc061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Changed ### Removed +## 4.0.0 - 2026-05-06 +### Added +- Composer scripts `cs-check` / `cs-fix` (PHPCS / PHPCBF); CI job **Auto-fix check** ensures the tree matches `phpcbf` output. +- Psalm (`psalm.xml`, `psalm-baseline.xml`): static analysis at `errorLevel="1"`. CI runs **`composer psalm`** (dev dependency `vimeo/psalm` **6.16.1**, aligned with `psalm-baseline.xml`). +### Changed +- **Breaking:** minimum supported PHP version is now **8.1** (PHP 7.x and 8.0 are no longer supported). This matches runtime requirements already implied by `EventType` (enum) and typed properties on `WebhookSubscription`. + ## 3.2.0 - 2026-05-05 ### Added - `Document::getContent(bool $signed = true)` — downloads document content as raw binary via the `/content` endpoint (no base64 JSON round-trip). Decrypted content only; the SDK does not expose `decrypt=false`. @@ -14,8 +21,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). - `Document::getFormat()` — returns the document format as reported by the API (typically `"pdf"` for current use). - `Entity::getBinaryContent()` — low-level helper for fetching raw binary responses from asset endpoints. ### Changed -- `Document::getPdf()` now delegates to `getContent()` and is deprecated. The underlying endpoint changed from the deprecated `/pdf` (base64) to `/content` (binary). Return value is unchanged (raw binary string). -- `Document::setPdfFile()` is deprecated in favour of `setFile()`. +- `Document::getPdf()` now delegates to `getContent()` and is deprecated; optional `bool $signed = true` matches `getContent()`. The underlying endpoint changed from the deprecated `/pdf` (base64) to `/content` (binary). Return value is unchanged (raw binary string). +- `Document::setPdfFile()` is deprecated in favor of `setFile()`. ## 3.1.1 - 2026-05-05 ### Added diff --git a/README.md b/README.md index e4885d9..70e1c45 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ about how to become a customer. ## Prerequisites -The Penneo SDK for PHP requires PHP 7.2 or newer (PHP 8 is supported). The PHP extensions `json` and `openssl` are required. +The Penneo SDK for PHP requires **PHP 8.1 or newer**. The PHP extensions `json` and `openssl` are required. + +If your application still runs on PHP 7.x or 8.0, stay on the **3.x** SDK line (e.g. `"penneo/penneo-sdk-php": "^3.2"`) and plan an upgrade. ## Getting Started @@ -27,7 +29,7 @@ Next, update your project's composer.json file to include the SDK: ```json { "require": { - "penneo/penneo-sdk-php": "^3.0" + "penneo/penneo-sdk-php": "^4.0" } } ``` @@ -187,6 +189,28 @@ request. You should add a logger by calling `ApiConnector::setLogger()`. If you contact support, please include any relevant `requestIds` you find in the logs. +### Static analysis (Psalm) + +CI runs [Psalm](https://psalm.dev/) on `src/` (`errorLevel="1"`, `psalm-baseline.xml`). With **PHP ^8.1**, Psalm is a normal **dev** dependency (`vimeo/psalm`); after `composer install` use: + +```bash +composer psalm +composer psalm:baseline # refresh psalm-baseline.xml after fixing/suppressing issues +``` + +(`bin/psalm` is the Composer-generated proxy to `vendor/vimeo/psalm/psalm`.) + +### Code style (PHPCS / PHPCBF) + +`phpcs.xml` applies **PSR-12** to `src/` and `tests/`. CI runs `bin/phpcs` on every PHP version and a dedicated job runs **`bin/phpcbf`** then fails if anything changed (so auto-fixable drift is caught). + +Locally: + +```bash +composer cs-check # bin/phpcs +composer cs-fix # bin/phpcbf — apply fixes, then commit +``` + ### Document signing * [Folders][folder-docs] diff --git a/composer.json b/composer.json index 9eccdb8..5908b22 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ } }, "require": { - "php": "^7.2|^8.0", + "php": "^8.1", "psr/log": "^1.0|^2.0|^3.0", "ext-json": "*", "guzzlehttp/guzzle": "^7.2", @@ -27,11 +27,18 @@ "phpunit/phpunit": "^8.5|^9.0", "squizlabs/php_codesniffer": "*", "blastcloud/chassis": "1.0.4|>=1.1", - "blastcloud/guzzler": "2.0.2|>=2.2" + "blastcloud/guzzler": "2.0.2|>=2.2", + "vimeo/psalm": "6.16.1" }, "config": { "bin-dir": "bin" }, + "scripts": { + "cs-check": "phpcs", + "cs-fix": "phpcbf", + "psalm": "@php bin/psalm --no-cache", + "psalm:baseline": "@php bin/psalm --update-baseline" + }, "archive": { "exclude": [ "tests/", diff --git a/docs/cli-examples.md b/docs/cli-examples.md index 6d7ca8e..839d9b4 100644 --- a/docs/cli-examples.md +++ b/docs/cli-examples.md @@ -30,7 +30,7 @@ Without real credentials, programmatic fails at token exchange (e.g. HTTP 401) a ### Interactive OAuth on localhost (step by step) -Penneo sends the user back to an URL you register on the OAuth client. **That URL, the address bar in the browser, and `PENNEO_OAUTH_REDIRECT_URI` must be identical** (including `http` vs `https`, `localhost` vs `127.0.0.1`, port, path, and **`/interactive_oauth_example.php`** — the file extension is required). +Penneo sends the user back to a URL you register on the OAuth client. **That URL, the address bar in the browser, and `PENNEO_OAUTH_REDIRECT_URI` must be identical** (including `http` vs `https`, `localhost` vs `127.0.0.1`, port, path, and **`/interactive_oauth_example.php`** — the file extension is required). 1. In Penneo (sandbox), add a redirect URI such as: `http://127.0.0.1:8080/interactive_oauth_example.php` diff --git a/docs/programmatic_oauth_example.php b/docs/programmatic_oauth_example.php index 5cd0958..668a089 100644 --- a/docs/programmatic_oauth_example.php +++ b/docs/programmatic_oauth_example.php @@ -1,5 +1,7 @@ + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..ca77050 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ApiConnector.php b/src/ApiConnector.php index 075661f..0b7f8cb 100644 --- a/src/ApiConnector.php +++ b/src/ApiConnector.php @@ -67,7 +67,7 @@ public static function initializeWsse( ?string $endpoint = null, ?int $user = null, ?array $headers = null - ) { + ): void { self::$headers = array_merge( $headers ?: [], self::getDefaultHeaders(), @@ -100,9 +100,13 @@ public static function setLogger(LoggerInterface $logger): void self::$logger = $logger; } - public static function readObject(Entity $object) + public static function readObject(Entity $object): bool { - $response = self::callServer($object->getRelativeUrl() . '/' . $object->getId()); + $id = $object->getId(); + if ($id === null) { + return false; + } + $response = self::callServer($object->getRelativeUrl() . '/' . $id); if (!$response) { return false; } @@ -110,16 +114,17 @@ public static function readObject(Entity $object) return true; } - public static function writeObject(Entity $object) + public static function writeObject(Entity $object): bool { $data = $object->__getRequestData(); if ($data === null) { return false; } - if ($object->getId()) { + $id = $object->getId(); + if ($id !== null) { // Update request - $response = self::callServer($object->getRelativeUrl() . '/' . $object->getId(), $data, 'put'); + $response = self::callServer($object->getRelativeUrl() . '/' . $id, $data, 'put'); if (!$response) { return false; } @@ -129,23 +134,37 @@ public static function writeObject(Entity $object) if (!$response) { return false; } - $object->__fromJson($response->getBody(true)); + $object->__fromJson($response->getBody()->getContents()); } return true; } - public static function deleteObject(Entity $object) + public static function deleteObject(Entity $object): bool { - if (!self::callServer($object->getRelativeUrl() . '/' . $object->getId(), null, 'delete')) { + $id = $object->getId(); + if ($id === null) { + return false; + } + if (!self::callServer($object->getRelativeUrl() . '/' . $id, null, 'delete')) { return false; } return true; } - public static function callServer($url, $data = null, $method = 'get', $options = array()): ?Response - { + /** + * @param false|null|string $data + * @param array[] $options + * + * @psalm-param array{query?: array} $options + */ + public static function callServer( + string $url, + $data = null, + string $method = 'get', + array $options = [] + ): ?Response { try { self::$logger->debug( 'request', @@ -234,9 +253,9 @@ private static function fixEndpoint(string $uri): string * * @param mixed $data * - * @return string|null + * @return false|null|string */ - private static function sanitizeData($data): ?string + private static function sanitizeData($data) { if ($data !== null && !is_string($data)) { $serializedData = json_encode($data); @@ -251,7 +270,7 @@ private static function sanitizeData($data): ?string return $data; } - public static function initializeOAuth(OAuth $oauth, string $apiVersion = 'v3') + public static function initializeOAuth(OAuth $oauth, string $apiVersion = 'v3'): void { $handler = HandlerStack::create(); $handler->push($oauth->getMiddleware()); diff --git a/src/CaseFile.php b/src/CaseFile.php index e76e726..b982cb4 100644 --- a/src/CaseFile.php +++ b/src/CaseFile.php @@ -99,7 +99,7 @@ public function getCaseFileTemplates() public function getDocumentTypes() { if (!$this->id) { - return array(); + return []; } return parent::getLinkedEntities($this, DocumentType::class, 'casefiles/' . $this->id . '/documenttypes'); } @@ -110,13 +110,13 @@ public function getDocumentTypes() public function getSignerTypes() { if (!$this->id) { - return array(); + return []; } return parent::getLinkedEntities($this, SignerType::class, 'casefiles/' . $this->id . '/signertypes'); } /** - * @return Document[]|bool|null + * @return Document[] */ public function getDocuments() { @@ -127,7 +127,7 @@ public function getDocuments() } /** - * @return Signer|bool|null + * @return Signer[] */ public function getSigners() { @@ -138,7 +138,7 @@ public function getSigners() } /** - * @return CopyRecipient|bool|null + * @return CopyRecipient[] */ public function getCopyRecipients() { @@ -149,11 +149,9 @@ public function getCopyRecipients() } /** - * @param $id - * - * @return Signer|null|false + * @param int|string $id */ - public function findSigner($id) + public function findSigner($id): ?Signer { if ($this->signers !== null) { foreach ($this->signers as $signer) { @@ -167,11 +165,9 @@ public function findSigner($id) } /** - * @param $id - * - * @return false|null|CopyRecipient + * @param int|string $id */ - public function findCopyRecipient($id) + public function findCopyRecipient($id): ?CopyRecipient { if ($this->copyRecipients !== null) { foreach ($this->copyRecipients as $recipient) { @@ -189,27 +185,22 @@ public function getErrors() return parent::getAssets($this, 'errors'); } - public function activate() + public function activate(): bool { return parent::callAction($this, 'activate'); } - public function send() + public function send(): bool { return parent::callAction($this, 'send'); } - public function getId() - { - return $this->id; - } - - public function getTitle() + public function getTitle(): string { return $this->title; } - public function setTitle($title) + public function setTitle($title): void { $this->title = $title; } @@ -224,42 +215,42 @@ public function setLanguage(string $language): void $this->language = $language; } - public function getMetaData() + public function getMetaData(): string { return $this->metaData; } - public function setMetaData($meta) + public function setMetaData($meta): void { $this->metaData = $meta; } - public function getSendAt() + public function getSendAt(): \DateTime { return new \DateTime('@' . $this->sendAt); } - public function setSendAt(\DateTime $sendAt) + public function setSendAt(\DateTime $sendAt): void { $this->sendAt = $sendAt->getTimestamp(); } - public function getExpireAt() + public function getExpireAt(): \DateTime { return new \DateTime('@' . $this->expireAt); } - public function setExpireAt(\DateTime $expireAt) + public function setExpireAt(\DateTime $expireAt): void { $this->expireAt = $expireAt->getTimestamp(); } - public function getVisibilityMode() + public function getVisibilityMode(): int { return $this->visibilityMode; } - public function setVisibilityMode($visibilityMode) + public function setVisibilityMode($visibilityMode): void { $this->visibilityMode = $visibilityMode; } @@ -366,7 +357,7 @@ public function setDisableEmailAttachments($disableEmailAttachments) return $this; } - public function getStatus() + public function getStatus(): string { switch ($this->status) { case 0: @@ -409,29 +400,30 @@ public function getReference() return $this->reference; } - public function getCreatedAt() + public function getCreatedAt(): \DateTime { return new \DateTime('@' . $this->created); } - public function getSignIteration() + public function getSignIteration(): int { return $this->signIteration; } /** - * @return CaseFileTemplate + * Resolved template for this case file, if the API returned one. */ - public function getCaseFileTemplate() + public function getCaseFileTemplate(): ?CaseFileTemplate { if ($this->id && !$this->caseFileType) { $caseFileTypes = parent::getLinkedEntities($this, CaseFileTemplate::class); - $this->caseFileType = $caseFileTypes[0]; + $first = $caseFileTypes[0] ?? null; + $this->caseFileType = $first instanceof CaseFileTemplate ? $first : null; } return $this->caseFileType; } - public function setCaseFileTemplate(CaseFileTemplate $template) + public function setCaseFileTemplate(CaseFileTemplate $template): void { $this->caseFileType = $template; } diff --git a/src/CopyRecipient.php b/src/CopyRecipient.php index 3193697..0d917ac 100644 --- a/src/CopyRecipient.php +++ b/src/CopyRecipient.php @@ -38,7 +38,7 @@ public function getName() return $this->name; } - public function setName($name) + public function setName($name): void { $this->name = $name; } @@ -48,7 +48,7 @@ public function getEmail() return $this->email; } - public function setEmail($email) + public function setEmail($email): void { $this->email = $email; } diff --git a/src/Customer.php b/src/Customer.php index f39f2e9..0555057 100644 --- a/src/Customer.php +++ b/src/Customer.php @@ -27,8 +27,12 @@ class Customer extends Entity public function getBranding() { if ($this->branding === null) { + $id = $this->getId(); + if ($id === null) { + return null; + } // Try to retrieve the branding from the backend. - $url = self::$relativeUrl . '/' . $this->getId() . '/branding'; + $url = self::$relativeUrl . '/' . $id . '/branding'; $this->branding = self::getEntity(CustomerBranding::class, $url, $this); } @@ -38,8 +42,12 @@ public function getBranding() public function getEmailSignature() { if ($this->emailSignature === null) { + $id = $this->getId(); + if ($id === null) { + return null; + } // Try to retrieve the email signature from the backend. - $url = self::$relativeUrl . '/' . $this->getId() . '/email-signature'; + $url = self::$relativeUrl . '/' . $id . '/email-signature'; $this->emailSignature = self::getEntity(EmailSignature::class, $url, $this); } @@ -49,7 +57,7 @@ public function getEmailSignature() /** * @param EmailSignature $emailSignature */ - public function setEmailSignature(EmailSignature $emailSignature) + public function setEmailSignature(EmailSignature $emailSignature): void { $this->emailSignature = $emailSignature; } diff --git a/src/CustomerBranding.php b/src/CustomerBranding.php index ff43d63..42eceb4 100644 --- a/src/CustomerBranding.php +++ b/src/CustomerBranding.php @@ -10,6 +10,7 @@ class CustomerBranding extends Entity protected $highlightColor; protected $textColor; protected $siteUrl; + /** @var Image|null */ protected $logo; protected $imageId; @@ -20,6 +21,9 @@ public function __construct(?Customer $customer = null) $this->customer = $customer; } + /** + * @return Customer|null + */ public function getParent() { return $this->customer; @@ -122,11 +126,21 @@ public function getImageId() return $this->imageId; } - public function getLogoUrl() + /** + * Absolute URL of the linked logo image, if {@see getImageId()} is set and the logo can be resolved. + */ + public function getLogoUrl(): ?string { if ($this->logo === null) { - // Fetch the logo url. - $this->logo = self::findLinkedEntity($this->customer, Image::class, $this->imageId); + $customer = $this->customer; + if ($customer === null) { + return null; + } + $this->logo = self::findLinkedEntity($customer, Image::class, $this->imageId); + } + + if (!$this->logo instanceof Image) { + return null; } return $this->logo->getUrl(); diff --git a/src/Document.php b/src/Document.php index c7edb85..b43cf25 100644 --- a/src/Document.php +++ b/src/Document.php @@ -2,6 +2,39 @@ namespace Penneo\SDK; +/** + * Document attached to a case file (Penneo REST: `/documents`, `/documents/{id}`, `/documents/{id}/content`, …). + * + * Upload today is PDF-only; `setFile()` maps to the API `file` body field (OpenAPI: form `file` or legacy `pdfFile`). + * Download uses `GET .../documents/{id}/content` with optional query `signed` + * (SDK sends `signed=false` when needed; API `decrypt` is left at default). + * + * @method static Document find(int|string $id) + * @method static Document[] findAll() + * @method static Document[] findBy( + * array $criteria, + * ?array $orderBy = null, + * ?int $limit = null, + * ?int $offset = null + * ) + * @method static Document[] findOneBy(array $criteria, ?array $orderBy = null) + * @method static Document[] findByTitle( + * string $title, + * ?array $orderBy = null, + * ?int $limit = null, + * ?int $offset = null + * ) + * @method static Document[] findOneByTitle(string $title, ?array $orderBy = null) + * @method static Document[] findByMetaData( + * string $metaData, + * ?array $orderBy = null, + * ?int $limit = null, + * ?int $offset = null + * ) + * @method static Document[] findOneByMetaData(string $metaData, ?array $orderBy = null) + * @method static void persist(Document $object) + * @method static void delete(Document $object) + */ class Document extends Entity { protected static $propertyMapping = array( @@ -40,27 +73,43 @@ class Document extends Entity protected $type = 'attachment'; protected $documentType; + /** @var SignatureLine[]|null */ protected $signatureLines = null; + /** + * @param CaseFile|null $caseFile Case file this document belongs to (required for create). + */ public function __construct(?CaseFile $caseFile = null) { $this->caseFile = $caseFile; } /** + * Case file that owns this document. + * * @return CaseFile + * + * @throws Exception */ public function getCaseFile() { if (!$this->caseFile) { $caseFiles = parent::getLinkedEntities($this, CaseFile::class); - $this->caseFile = $caseFiles[0]; + $first = $caseFiles[0] ?? null; + if (!$first instanceof CaseFile) { + throw new Exception('Penneo: Case file not found for document'); + } + $this->caseFile = $first; } return $this->caseFile; } /** + * Signature lines defined on this document. + * * @return SignatureLine[] + * + * @throws Exception */ public function getSignatureLines() { @@ -71,9 +120,13 @@ public function getSignatureLines() } /** - * @param $id + * Find a signature line by id on this document. * - * @return SignatureLine|false + * @param int|string $id Signature line id + * + * @return SignatureLine|false|null|static Null when not found in the cached list; null when API returns no entity + * + * @throws Exception */ public function findSignatureLine($id) { @@ -89,14 +142,17 @@ public function findSignatureLine($id) } /** - * Download the document content as raw binary via GET .../content. + * Raw document bytes from `GET /documents/{id}/content`. + * Response is binary when the client does not negotiate `Accept: application/json`. * * Upload is PDF-only in the API today; this returns whatever bytes the API stores for the document. * Content is always returned decrypted (API default); the encrypted storage blob is not exposed via the SDK. * * @param bool $signed Get the signed version when available (default: true). Pass false for the original document. * - * @return string Raw binary content + * @return string Binary response body + * + * @throws Exception When the HTTP request fails or the SDK returns no response */ public function getContent(bool $signed = true): string { @@ -109,28 +165,33 @@ public function getContent(bool $signed = true): string } /** - * @deprecated Use getContent() instead. - * * @param bool $signed Get the signed version when available (default: true). Pass false for the original document. * * @return string Raw binary PDF content + * @deprecated Use {@see self::getContent()} instead. + * */ public function getPdf(bool $signed = true): string { return $this->getContent($signed); } + /** + * Mark the document as signable (`type`: `signable`) before {@see Entity::persist()}. + * + * @return void + */ public function makeSignable() { $this->type = 'signable'; } /** - * Set the document file from a local path. The file is base64-encoded and sent as the API `file` - * field (alongside the legacy `pdfFile` from setPdfFile()). Only PDF uploads are supported by - * the API today; this naming prepares for additional formats without breaking compatibility. + * Local path to the file; encoded as base64 in the JSON `file` field on create (OpenAPI form field `file`). * - * @param string $filePath Path to the local PDF file + * @param string $filePath Path to the local PDF file supported by the API today + * + * @return void */ public function setFile(string $filePath): void { @@ -138,7 +199,11 @@ public function setFile(string $filePath): void } /** - * @deprecated Use setFile() instead. + * @param string $pdfFile Path to a readable PDF file + * + * @return void + * @deprecated Use {@see self::setFile()} instead (same JSON key `pdfFile` on the wire). + * */ public function setPdfFile($pdfFile) { @@ -146,12 +211,11 @@ public function setPdfFile($pdfFile) } /** - * Return the document format as reported by the API. + * Document format from the API numeric `format` field, mapped to a short string when known. * - * In practice this is `"pdf"` for current integrations. Other numeric format codes from the API - * are mapped when present; additional format names may apply as the API evolves. + * Typical value today: `"pdf"`. Unknown integer codes fall back to `(string) $format`. * - * @return string|null + * @return string|null Mapped label such as `pdf`, `xml`, `xhtml`, `zip`, or null if not hydrated */ public function getFormat(): ?string { @@ -166,49 +230,84 @@ public function getFormat(): ?string 4 => 'zip', ]; - return $formats[$this->format] ?? (string) $this->format; + return $formats[$this->format] ?? (string)$this->format; } + /** + * External identifier stamped on document pages (API: `documentId`). + * + * @return int|string|null + */ public function getDocumentId() { return $this->documentId; } + /** + * @return string|null + */ public function getTitle() { return $this->title; } + /** + * @param string $title + * + * @return void + */ public function setTitle($title) { $this->title = $title; } + /** + * @return string|null + */ public function getMetaData() { return $this->metaData; } + /** + * @param string|null $meta + * + * @return void + */ public function setMetaData($meta) { $this->metaData = $meta; } + /** + * @return \DateTime + */ public function getCreatedAt() { - return new \Datetime('@' . $this->created); + return new \DateTime('@' . $this->created); } + /** + * @return \DateTime + */ public function getModifiedAt() { - return new \Datetime('@' . $this->modified); + return new \DateTime('@' . $this->modified); } + /** + * @return \DateTime + */ public function getCompletedAt() { - return new \Datetime('@' . $this->completed); + return new \DateTime('@' . $this->completed); } + /** + * Human-readable lifecycle status derived from the API numeric `status` field. + * + * @return 'new'|'pending'|'rejected'|'deleted'|'signed'|'completed' + */ public function getStatus() { switch ($this->status) { @@ -229,13 +328,22 @@ public function getStatus() return 'deleted'; } + /** + * Document options / opts JSON string as stored by the API. + * + * @return string|null + */ public function getOptions() { return $this->options; } /** - * @return DocumentType + * Linked document type (lazy-loaded from `.../documenttype`). + * + * @return DocumentType|null + * + * @throws Exception */ public function getDocumentType() { @@ -246,6 +354,11 @@ public function getDocumentType() return $this->documentType; } + /** + * @param DocumentType $type + * + * @return void + */ public function setDocumentType(DocumentType $type) { $this->documentType = $type; diff --git a/src/Entity.php b/src/Entity.php index b400c62..772a671 100644 --- a/src/Entity.php +++ b/src/Entity.php @@ -4,18 +4,37 @@ abstract class Entity { - /** @var int */ + /** @var int|null */ protected $id; + + /** @var array */ protected static $propertyMapping = array( - 'create' => array(), - 'update' => array() + 'create' => [], + 'update' => [] ); + + /** @var string */ protected static $relativeUrl; + /** + * @throws Exception + */ + private static function persistedEntityId(Entity $entity): int + { + $id = $entity->getId(); + if ($id === null) { + throw new Exception('Penneo: Entity must be persisted for this operation'); + } + + return $id; + } + /** * @param $id * * @return static + * + * @psalm-suppress UnsafeInstantiation */ public static function find($id) { @@ -35,29 +54,31 @@ public static function find($id) */ public static function findAll() { - return self::findBy(array()); + return self::findBy([]); } /** * @param array $criteria * @param array|null $orderBy - * @param null $limit - * @param null $offset + * @param int|null $limit + * @param int|null $offset * * @return static[] * @throws \Exception + * + * @psalm-suppress UnsafeInstantiation */ - public static function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null) + public static function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null) { $class = get_called_class(); // Build query array $query = $criteria; if ($limit !== null) { - $query['limit'] = (int) $limit; + $query['limit'] = $limit; } if ($offset !== null) { - $query['offset'] = (int) $offset; + $query['offset'] = $offset; } // Build order by parameters. @@ -77,21 +98,32 @@ public static function findBy(array $criteria, ?array $orderBy = null, $limit = throw new Exception('Penneo: Internal problem encountered'); } - $matches = json_decode($response->getBody()->getContents(), true); + $decoded = json_decode($response->getBody()->getContents(), true); + if (!is_array($decoded)) { + throw new Exception('Penneo: Internal problem encountered'); + } + + /** @var array $matches */ + $matches = $decoded; if (count($matches) === 1 && isset($matches['items'])) { // In order to build the result (an array), we need an array. // But there might be an endpoint which returns an object (and not an array). // If that is the case and this object has one property called 'items', // let's use the value of that property to generate the result. - $matches = $matches['items']; + $items = $matches['items']; + $matches = is_array($items) ? $items : array($items); } - $result = array(); + $result = []; foreach ($matches as $match) { - $object = new $class(); - $object->__fromArray($match); - $result[] = $object; + if (!is_array($match)) { + continue; + } + /** @var static $instance */ + $instance = new $class(); + $instance->__fromArray($match); + $result[] = $instance; } return $result; @@ -166,15 +198,19 @@ public static function __callStatic($method, $arguments) } /** + * @template T of Entity * @param Entity $parent - * @param $type - * @param $id + * @param class-string $type + * @param int|string $id * - * @return static|false|null + * @return T|null + * + * @psalm-suppress InvalidPropertyFetch */ - public static function findLinkedEntity(Entity $parent, $type, $id) + public static function findLinkedEntity(Entity $parent, string $type, $id) { - $url = $parent->getRelativeUrl() . '/' . $parent->getId() . '/' . $type::$relativeUrl . '/' . $id; + $p = self::persistedEntityId($parent); + $url = $parent->getRelativeUrl() . '/' . $p . '/' . $type::$relativeUrl . '/' . $id; $entity = self::getEntity($type, $url, $parent); if ($entity === false) { @@ -185,18 +221,24 @@ public static function findLinkedEntity(Entity $parent, $type, $id) } /** + * @template T of Entity * @param Entity $parent - * @param string $type Full class path of the linked entity type - * @param null $url Force the use of a certain URL instead of the auto-detected one - * @param array $getParams Extra params to be added to the url; must be a associative array of GET params + * @param class-string $type Full class name of the linked entity type + * @param null|string $url + * Force the use of a certain URL instead of the auto-detected one + * @param array> $getParams + * Associative GET parameters + * + * @return list * - * @return array|bool * @throws Exception + * + * @psalm-suppress InvalidPropertyFetch */ - public static function getLinkedEntities(Entity $parent, $type, $url = null, array $getParams = array()) + public static function getLinkedEntities(Entity $parent, string $type, $url = null, array $getParams = []) { if ($url == null) { - $url = $parent->getRelativeUrl() . '/' . $parent->getId() . '/' . $type::$relativeUrl; + $url = $parent->getRelativeUrl() . '/' . self::persistedEntityId($parent) . '/' . $type::$relativeUrl; } if ($getParams) { @@ -211,7 +253,15 @@ public static function getLinkedEntities(Entity $parent, $type, $url = null, arr return $entities; } - public static function getEntity($type, $url, ?Entity $parent = null) + /** + * @template T of Entity + * @param class-string $type + * + * @return T|false|null + * + * @psalm-suppress UnsafeInstantiation + */ + public static function getEntity(string $type, string $url, ?Entity $parent = null) { $response = ApiConnector::callServer($url); if ($response === null) { @@ -219,12 +269,14 @@ public static function getEntity($type, $url, ?Entity $parent = null) } $data = json_decode($response->getBody()->getContents(), true); - if (!$data) { + if (!is_array($data) || !$data) { return null; } if ($parent) { + /** @var T $entity */ $entity = new $type($parent); } else { + /** @var T $entity */ $entity = new $type(); } @@ -233,7 +285,15 @@ public static function getEntity($type, $url, ?Entity $parent = null) return $entity; } - public static function getEntities($type, $url, ?Entity $parent = null) + /** + * @template T of Entity + * @param class-string $type + * + * @return list|false + * + * @psalm-suppress UnsafeInstantiation + */ + public static function getEntities(string $type, string $url, ?Entity $parent = null) { $response = ApiConnector::callServer($url); if (!$response) { @@ -241,12 +301,21 @@ public static function getEntities($type, $url, ?Entity $parent = null) } $dataSets = json_decode($response->getBody()->getContents(), true); + if (!is_array($dataSets)) { + throw new Exception('Penneo: Internal problem encountered'); + } + $entities = []; foreach ($dataSets as $data) { + if (!is_array($data)) { + continue; + } if ($parent) { + /** @var T $entity */ $entity = new $type($parent); } else { + /** @var T $entity */ $entity = new $type(); } $entity->__fromArray($data); @@ -256,9 +325,14 @@ public static function getEntities($type, $url, ?Entity $parent = null) return $entities; } - public static function linkEntity(Entity $parent, Entity $child) + /** + * @return true + */ + public static function linkEntity(Entity $parent, Entity $child): bool { - $url = $parent->getRelativeUrl() . '/' . $parent->getId() . '/' . $child::$relativeUrl . '/' . $child->getId(); + $p = self::persistedEntityId($parent); + $c = self::persistedEntityId($child); + $url = $parent->getRelativeUrl() . '/' . $p . '/' . $child::$relativeUrl . '/' . $c; $response = ApiConnector::callServer($url, null, 'LINK'); if (!$response) { @@ -268,9 +342,14 @@ public static function linkEntity(Entity $parent, Entity $child) return true; } - public static function unlinkEntity(Entity $parent, Entity $child) + /** + * @return true + */ + public static function unlinkEntity(Entity $parent, Entity $child): bool { - $url = $parent->getRelativeUrl() . '/' . $parent->getId() . '/' . $child::$relativeUrl . '/' . $child->getId(); + $p = self::persistedEntityId($parent); + $c = self::persistedEntityId($child); + $url = $parent->getRelativeUrl() . '/' . $p . '/' . $child::$relativeUrl . '/' . $c; $response = ApiConnector::callServer($url, null, 'UNLINK'); if (!$response) { @@ -280,9 +359,14 @@ public static function unlinkEntity(Entity $parent, Entity $child) return true; } - public static function getAssets(Entity $parent, $assetName) + /** + * @psalm-param 'errors'|'link'|'pdf' $assetName + * + * @psalm-return list + */ + public static function getAssets(Entity $parent, string $assetName): array { - $url = $parent->getRelativeUrl() . '/' . $parent->getId() . '/' . $assetName; + $url = $parent->getRelativeUrl() . '/' . self::persistedEntityId($parent) . '/' . $assetName; $response = ApiConnector::callServer($url); if (!$response) { @@ -290,7 +374,10 @@ public static function getAssets(Entity $parent, $assetName) } $assets = json_decode($response->getBody()->getContents(), true); - $result = array(); + if (!is_array($assets)) { + throw new Exception('Penneo: Internal problem encountered fetching assets: ' . $assetName); + } + $result = []; foreach ($assets as $asset) { $result[] = $asset; @@ -315,7 +402,7 @@ public static function getAssets(Entity $parent, $assetName) */ public static function getBinaryContent(Entity $parent, string $assetPath, array $queryParams = []): string { - $url = $parent->getRelativeUrl() . '/' . $parent->getId() . '/' . $assetPath; + $url = $parent->getRelativeUrl() . '/' . self::persistedEntityId($parent) . '/' . $assetPath; if ($queryParams) { $url .= '?' . http_build_query($queryParams); } @@ -328,9 +415,18 @@ public static function getBinaryContent(Entity $parent, string $assetPath, array return $response->getBody()->getContents(); } - public static function callAction(Entity $parent, string $actionName, string $method = 'patch', $data = null): bool - { - $url = $parent->getRelativeUrl() . '/' . $parent->getId() . '/' . $actionName; + /** + * @param null|string[] $data + * + * @psalm-param array{token: string}|null $data + */ + public static function callAction( + Entity $parent, + string $actionName, + string $method = 'patch', + ?array $data = null + ): bool { + $url = $parent->getRelativeUrl() . '/' . self::persistedEntityId($parent) . '/' . $actionName; $response = ApiConnector::callServer($url, $data !== null ? json_encode($data) : null, $method); if (!$response) { @@ -340,6 +436,9 @@ public static function callAction(Entity $parent, string $actionName, string $me return true; } + /** + * @return void + */ public static function persist(Entity $object) { if (!ApiConnector::writeObject($object)) { @@ -347,6 +446,9 @@ public static function persist(Entity $object) } } + /** + * @return void + */ public static function delete(Entity $object) { if (!ApiConnector::deleteObject($object)) { @@ -356,7 +458,7 @@ public static function delete(Entity $object) $object->id = null; } - public function __getMapping() + public function __getMapping(): ?array { $class = get_called_class(); $mapping = $class::$propertyMapping; @@ -366,13 +468,16 @@ public function __getMapping() return isset($mapping['create']) ? $mapping['create'] : null; } - public function __fromJson($json) + public function __fromJson($json): void { - $data = json_decode($json, true); + $data = json_decode((string) $json, true); + if (!is_array($data)) { + return; + } $this->__fromArray($data); } - public function __fromArray(array $data) + public function __fromArray(array $data): void { foreach ($data as $key => $val) { if (property_exists($this, $key)) { @@ -381,7 +486,15 @@ public function __fromArray(array $data) } } - private function parseObjects($data, $parent) + /** + * @param mixed $data + * @param static $parent + * + * @return mixed + * + * @psalm-suppress UnsafeInstantiation Hydration instantiates only validated Entity subclasses. + */ + private function parseObjects($data, self $parent) { // If we don't have an array, we are done. if (!is_array($data)) { @@ -390,14 +503,19 @@ private function parseObjects($data, $parent) // Check if we an object if (isset($data['sdkClassName'])) { - $class = 'Penneo\\SDK\\' . $data['sdkClassName']; + $class = 'Penneo\\SDK\\' . (string) $data['sdkClassName']; + if (!class_exists($class) || !is_subclass_of($class, self::class)) { + return $data; + } + /** @var class-string $class */ + /** @var Entity $obj */ $obj = new $class($parent); $obj->__fromArray($data); return $obj; } // If we reach this point, parse all objects in the array. - $parsedArray = array(); + $parsedArray = []; foreach ($data as $key => $element) { $parsedArray[$key] = $this->parseObjects($element, $parent); } @@ -405,9 +523,12 @@ private function parseObjects($data, $parent) return $parsedArray; } - public function __getRequestData() + /** + * @return string|null JSON string or null when mapping is missing + */ + public function __getRequestData(): ?string { - $data = array(); + $data = []; $mapping = $this->__getMapping(); if ($mapping === null) { return null; @@ -416,12 +537,16 @@ public function __getRequestData() foreach ($mapping as $key => $property) { // Process file entries $isFile = false; - if ($property[0] == '@') { + if (is_string($property) && $property !== '' && $property[0] == '@') { // This is a file. $isFile = true; $property = ltrim($property, '@'); } + if (!is_string($property)) { + continue; + } + // Decode the property value (if needed). $propValue = $this->__getPropertyValue($property); if ($propValue === null) { @@ -434,7 +559,11 @@ public function __getRequestData() continue; } - $propValue = base64_encode(file_get_contents($propValue)); + $raw = file_get_contents($propValue); + if ($raw === false) { + continue; + } + $propValue = base64_encode($raw); } if (is_int($key)) { @@ -444,13 +573,23 @@ public function __getRequestData() } } - return json_encode($data); + $encoded = json_encode($data); + if ($encoded === false) { + return null; + } + + return $encoded; } + /** + * @param int|string $property Property path with optional `->` chain + * + * @return mixed + */ public function __getPropertyValue($property) { // NOTE: Properties can actually be properties of properties. - $bits = explode('->', $property); + $bits = explode('->', (string) $property); $propValue = $this; foreach ($bits as $bit) { if (property_exists($propValue, $bit)) { @@ -469,24 +608,33 @@ public function __getPropertyValue($property) return $propValue; } + /** + * @return int|null + */ public function getId() { return $this->id; } + /** + * @return Entity|null + */ public function getParent() { return null; } - public function getRelativeUrl() + /** + * @return string + */ + public function getRelativeUrl(): string { $class = get_called_class(); $parent = $this->getParent(); $url = $class::$relativeUrl; if ($parent) { - $url = $parent::$relativeUrl . '/' . $parent->getId() . '/' . $url; + $url = $parent::$relativeUrl . '/' . self::persistedEntityId($parent) . '/' . $url; } return $url; diff --git a/src/Folder.php b/src/Folder.php index 271ec85..e687187 100644 --- a/src/Folder.php +++ b/src/Folder.php @@ -16,12 +16,13 @@ class Folder extends Entity * @param int|null $page Page numbers start at 1 * @param int $perPage Does nothing if $page is null * - * @return array + * @return array|bool + * * @throws Exception */ public function getCaseFiles($page = null, $perPage = PHP_INT_MAX) { - $paging = $page !== null ? array('page' => $page, 'per_page' => $perPage) : array(); + $paging = $page !== null ? array('page' => $page, 'per_page' => $perPage) : []; return parent::getLinkedEntities($this, CaseFile::class, null, $paging); } @@ -41,7 +42,7 @@ public function getTitle() return $this->title; } - public function setTitle($title) + public function setTitle($title): void { $this->title = $title; } diff --git a/src/LogEntry.php b/src/LogEntry.php index 0c2162c..f3dd1d5 100644 --- a/src/LogEntry.php +++ b/src/LogEntry.php @@ -13,7 +13,7 @@ public function getEventType() return $this->eventType; } - public function getEventTime() + public function getEventTime(): \DateTime { return new \DateTime('@' . $this->eventTime); } diff --git a/src/Message.php b/src/Message.php index f73588b..37efe16 100644 --- a/src/Message.php +++ b/src/Message.php @@ -14,13 +14,13 @@ public static function retrieve($limit = 10) { $response = ApiConnector::callServer('messages/' . $limit); if (!$response) { - return array(); + return []; } return json_decode($response->getBody()->getContents(), true); } - public static function delete($id) + public static function delete($id): bool { return ApiConnector::callServer('message/' . $id, null, 'delete') !== null; } diff --git a/src/MessageTemplate.php b/src/MessageTemplate.php index bbcfa57..2027e46 100644 --- a/src/MessageTemplate.php +++ b/src/MessageTemplate.php @@ -27,7 +27,7 @@ public function getTitle() return $this->title; } - public function setTitle($title) + public function setTitle($title): void { $this->title = $title; } @@ -37,7 +37,7 @@ public function getSubject() return $this->subject; } - public function setSubject($subject) + public function setSubject($subject): void { $this->subject = $subject; } @@ -47,7 +47,7 @@ public function getMessage() return $this->message; } - public function setMessage($message) + public function setMessage($message): void { $this->message = $message; } diff --git a/src/OAuth/ApiKeysMiddleware.php b/src/OAuth/ApiKeysMiddleware.php index 126e33d..08aa57b 100644 --- a/src/OAuth/ApiKeysMiddleware.php +++ b/src/OAuth/ApiKeysMiddleware.php @@ -4,6 +4,7 @@ use Penneo\SDK\OAuth\Tokens\PenneoTokensValidator; use Penneo\SDK\OAuth\Tokens\TokenStorage; +use Penneo\SDK\PenneoSdkRuntimeException; use Psr\Http\Message\RequestInterface; class ApiKeysMiddleware @@ -24,9 +25,16 @@ public function handleRequest(RequestInterface $request): RequestInterface { $this->refreshAccessToken(); + $tokens = $this->tokenStorage->getTokens(); + if ($tokens === null) { + throw new PenneoSdkRuntimeException( + 'OAuth tokens are not available. Complete an OAuth or API key exchange flow first.' + ); + } + return $request->withHeader( 'Authorization', - "Bearer {$this->tokenStorage->getTokens()->getAccessToken()}" + "Bearer {$tokens->getAccessToken()}" ); } diff --git a/src/OAuth/OAuthApi.php b/src/OAuth/OAuthApi.php index 6c8507d..5321959 100644 --- a/src/OAuth/OAuthApi.php +++ b/src/OAuth/OAuthApi.php @@ -102,7 +102,11 @@ private function post(array $payload): PenneoTokens ); } - /** @throws PenneoSdkRuntimeException */ + /** + * @throws PenneoSdkRuntimeException + * + * @return never + */ private function handleBadResponse(ResponseInterface $response, string $title) { $body = json_decode($response->getBody()); @@ -132,6 +136,11 @@ private function buildCodeExchangePayload(string $code, string $codeVerifier): a public function postTokenRefresh(): PenneoTokens { $stored = $this->tokenStorage->getTokens(); + if ($stored === null) { + throw new PenneoSdkRuntimeException( + 'Cannot refresh OAuth tokens: no token storage state is available.' + ); + } $refreshToken = $stored->getRefreshToken(); if ($refreshToken === null || $refreshToken === '') { throw new PenneoSdkRuntimeException( @@ -141,16 +150,16 @@ public function postTokenRefresh(): PenneoTokens } return $this->postOrThrow( - $this->buildTokenRefreshPayload(), + $this->buildTokenRefreshPayload($stored), "refresh tokens" ); } - private function buildTokenRefreshPayload(): array + private function buildTokenRefreshPayload(PenneoTokens $stored): array { return [ 'grant_type' => 'refresh_token', - 'refresh_token' => $this->tokenStorage->getTokens()->getRefreshToken(), + 'refresh_token' => $stored->getRefreshToken(), 'redirect_uri' => $this->config->getRedirectUri(), 'client_id' => $this->config->getClientId(), 'client_secret' => $this->config->getClientSecret(), @@ -171,7 +180,13 @@ private function buildApiKeyExchangePayload(): array $moment = ($this->nowFactory)(); $createdAt = self::formatApiKeyDigestCreatedAt($moment); $nonce = $this->nonceGenerator->generate(); - $digest = base64_encode(sha1($nonce . $createdAt . $this->config->getApiSecret(), true)); + $apiSecret = $this->config->getApiSecret(); + if ($apiSecret === null || $apiSecret === '') { + throw new PenneoSdkRuntimeException( + 'Cannot exchange API credentials: client API secret is not configured.' + ); + } + $digest = base64_encode(sha1($nonce . $createdAt . $apiSecret, true)); return [ 'grant_type' => 'api_keys', diff --git a/src/OAuth/RefreshTokenMiddleware.php b/src/OAuth/RefreshTokenMiddleware.php index 4a270f5..ccd6cc1 100644 --- a/src/OAuth/RefreshTokenMiddleware.php +++ b/src/OAuth/RefreshTokenMiddleware.php @@ -25,13 +25,21 @@ public function __construct(TokenStorage $tokenStorage, OAuthApi $api) public function handleRequest(RequestInterface $request): RequestInterface { $tokens = $this->tokenStorage->getTokens(); + if ($tokens === null) { + throw new AuthenticationExpiredException('Session has expired, please reauthenticate!'); + } $this->validateBothTokensNotExpired($tokens); $this->refreshExpiredAccessToken($tokens); + $tokens = $this->tokenStorage->getTokens(); + if ($tokens === null) { + throw new AuthenticationExpiredException('Session has expired, please reauthenticate!'); + } + return $request->withHeader( 'Authorization', - "Bearer {$this->tokenStorage->getTokens()->getAccessToken()}" + "Bearer {$tokens->getAccessToken()}" ); } diff --git a/src/OAuth/Tokens/PenneoTokens.php b/src/OAuth/Tokens/PenneoTokens.php index 002d09b..caab2b0 100644 --- a/src/OAuth/Tokens/PenneoTokens.php +++ b/src/OAuth/Tokens/PenneoTokens.php @@ -47,7 +47,10 @@ public function __construct( $this->refreshTokenExpiresAt = $refreshTokenExpiresAt; } - public function serialize(): string + /** + * @return false|string + */ + public function serialize() { return json_encode([ 'accessToken' => $this->getAccessToken(), diff --git a/src/OAuth/Tokens/PenneoTokensValidator.php b/src/OAuth/Tokens/PenneoTokensValidator.php index 148e300..8f9f596 100644 --- a/src/OAuth/Tokens/PenneoTokensValidator.php +++ b/src/OAuth/Tokens/PenneoTokensValidator.php @@ -14,9 +14,11 @@ public static function areNotExpired(?PenneoTokens $tokens = null): bool $now = \time(); + $refreshExp = $tokens->getRefreshTokenExpiresAt(); + return $tokens->getAccessToken() && ($now < $tokens->getAccessTokenExpiresAt() - self::TOKEN_EXPIRY_BUFFER_IN_SECONDS - || $now < $tokens->getRefreshTokenExpiresAt() - self::TOKEN_EXPIRY_BUFFER_IN_SECONDS); + || ($refreshExp !== null && $now < $refreshExp - self::TOKEN_EXPIRY_BUFFER_IN_SECONDS)); } public static function isAccessTokenExpired(PenneoTokens $tokens): bool diff --git a/src/OAuth/Tokens/SessionTokenStorage.php b/src/OAuth/Tokens/SessionTokenStorage.php index 1dd5795..00fc67d 100644 --- a/src/OAuth/Tokens/SessionTokenStorage.php +++ b/src/OAuth/Tokens/SessionTokenStorage.php @@ -12,6 +12,9 @@ public function __construct(string $keyInSession = 'penneoOAuthTokens') $this->keyInSession = $keyInSession; } + /** + * @return void + */ public function saveTokens(PenneoTokens $tokens) { $_SESSION[$this->keyInSession] = $tokens->serialize(); diff --git a/src/SignatureLine.php b/src/SignatureLine.php index e00d849..a991560 100644 --- a/src/SignatureLine.php +++ b/src/SignatureLine.php @@ -11,6 +11,7 @@ class SignatureLine extends Entity protected static $relativeUrl = 'signaturelines'; protected $document; + /** @var Signer|null */ protected $signer = null; protected $role; protected $conditions; @@ -24,24 +25,27 @@ public function __construct(Document $document) $this->document = $document; } + /** + * @return Document + */ public function getParent() { return $this->document; } /** - * @return Signer + * @return Signer|null */ - public function getSigner() + public function getSigner(): ?Signer { if ($this->signer == null) { if ($this->signerId !== null) { - // Retrieve signer from signer id - $this->signer = $this->document->getCaseFile()->findSigner($this->signerId); + $found = $this->document->getCaseFile()->findSigner($this->signerId); + $this->signer = $found instanceof Signer ? $found : null; } else { - // Retrieve signer from API $signers = parent::getLinkedEntities($this, Signer::class); - $this->signer = $signers[0]; + $first = $signers[0] ?? null; + $this->signer = $first instanceof Signer ? $first : null; } } @@ -59,7 +63,7 @@ public function getRole() return $this->role; } - public function setRole($role) + public function setRole($role): void { $this->role = $role; } @@ -69,7 +73,7 @@ public function getConditions() return $this->conditions; } - public function setConditions($conditions) + public function setConditions($conditions): void { $this->conditions = $conditions; } @@ -79,12 +83,12 @@ public function getSignOrder() return $this->signOrder; } - public function setSignOrder($signOrder) + public function setSignOrder($signOrder): void { $this->signOrder = $signOrder; } - public function getSignedAt() + public function getSignedAt(): ?\DateTime { if ($this->signedAt) { return new \DateTime('@' . $this->signedAt); diff --git a/src/Signer.php b/src/Signer.php index 4fe2168..2cf917f 100644 --- a/src/Signer.php +++ b/src/Signer.php @@ -40,17 +40,19 @@ class Signer extends Entity protected $storeAsContact; - /** @var CaseFile */ + /** @var CaseFile|null */ protected $caseFile; /** @var SigningRequest|null */ protected $signingRequest = null; /** * @param CaseFile|SignatureLine $parent + * + * @psalm-suppress RedundantConditionGivenDocblockType + * $parent is untyped for BC; runtime can receive other Entity subclasses via hydration. */ public function __construct($parent) { - $this->caseFile = null; if ($parent instanceof CaseFile) { $this->caseFile = $parent; } elseif ($parent instanceof SignatureLine) { @@ -83,7 +85,7 @@ public function getName(): string return $this->name; } - public function setName(string $name) + public function setName(string $name): void { $this->name = $name; } @@ -98,7 +100,7 @@ public function getSocialSecurityNumber(): ?string return $this->socialSecurityNumberPlain; } - public function setSocialSecurityNumber(string $ssn, string $ssnType = 'legacy') + public function setSocialSecurityNumber(string $ssn, string $ssnType = 'legacy'): void { $this->socialSecurityNumberPlain = $ssn; $this->ssnType = $ssnType; @@ -123,7 +125,7 @@ public function getVATIdentificationNumber(): ?string return $this->vatin; } - public function setVATIdentificationNumber(string $vatin) + public function setVATIdentificationNumber(string $vatin): void { $this->vatin = $vatin; } @@ -133,7 +135,7 @@ public function getOnBehalfOf(): ?string return $this->onBehalfOf; } - public function setOnBehalfOf(string $onBehalfOf) + public function setOnBehalfOf(string $onBehalfOf): void { $this->onBehalfOf = $onBehalfOf; } diff --git a/src/SigningRequest.php b/src/SigningRequest.php index c23a3cf..577e580 100644 --- a/src/SigningRequest.php +++ b/src/SigningRequest.php @@ -56,7 +56,7 @@ public function getLink() return $data[0]; } - public function send() + public function send(): bool { return parent::callAction($this, 'send'); } @@ -66,7 +66,7 @@ public function getEmail() return $this->email; } - public function setEmail($email) + public function setEmail($email): void { $this->email = $email; } @@ -76,7 +76,7 @@ public function getEmailSubject() return $this->emailSubject; } - public function setEmailSubject($emailSubject) + public function setEmailSubject($emailSubject): void { $this->emailSubject = $emailSubject; } @@ -86,7 +86,7 @@ public function getEmailText() return $this->emailText; } - public function setEmailText($emailText) + public function setEmailText($emailText): void { $this->emailText = $emailText; } @@ -96,7 +96,7 @@ public function getReminderEmailSubject() return $this->reminderEmailSubject; } - public function setReminderEmailSubject($reminderEmailSubject) + public function setReminderEmailSubject($reminderEmailSubject): void { $this->reminderEmailSubject = $reminderEmailSubject; } @@ -106,7 +106,7 @@ public function getReminderEmailText() return $this->reminderEmailText; } - public function setReminderEmailText($reminderEmailText) + public function setReminderEmailText($reminderEmailText): void { $this->reminderEmailText = $reminderEmailText; } @@ -116,7 +116,7 @@ public function getCompletedEmailSubject() return $this->completedEmailSubject; } - public function setCompletedEmailSubject($completedEmailSubject) + public function setCompletedEmailSubject($completedEmailSubject): void { $this->completedEmailSubject = $completedEmailSubject; } @@ -126,12 +126,12 @@ public function getCompletedEmailText() return $this->completedEmailText; } - public function setCompletedEmailText($completedEmailText) + public function setCompletedEmailText($completedEmailText): void { $this->completedEmailText = $completedEmailText; } - public function getStatus() + public function getStatus(): string { switch ($this->status) { case 0: @@ -161,7 +161,7 @@ public function getEmailFormat() return $this->emailFormat; } - public function setEmailFormat($format) + public function setEmailFormat($format): void { $this->emailFormat = $format; } @@ -171,7 +171,7 @@ public function getSuccessUrl() return $this->successUrl; } - public function setSuccessUrl($url) + public function setSuccessUrl($url): void { $this->successUrl = $url; } @@ -181,7 +181,7 @@ public function getFailUrl() return $this->failUrl; } - public function setFailUrl($url) + public function setFailUrl($url): void { $this->failUrl = $url; } @@ -191,7 +191,7 @@ public function getReminderInterval() return $this->reminderInterval; } - public function setReminderInterval($interval) + public function setReminderInterval($interval): void { $this->reminderInterval = $interval; } @@ -211,7 +211,7 @@ public function getEnableInsecureSigning() return $this->enableInsecureSigning; } - public function setEnableInsecureSigning($enableInsecureSigning) + public function setEnableInsecureSigning($enableInsecureSigning): void { $this->enableInsecureSigning = $enableInsecureSigning; } diff --git a/src/Validation.php b/src/Validation.php index 794615a..3c4384f 100644 --- a/src/Validation.php +++ b/src/Validation.php @@ -38,7 +38,7 @@ class Validation extends Entity protected $customText; protected $status; - public function getPdf() + public function getPdf(): string { $data = parent::getAssets($this, 'pdf'); return base64_decode($data[0]); @@ -50,7 +50,7 @@ public function getLink() return $data[0]; } - public function send() + public function send(): bool { return parent::callAction($this, 'send'); } @@ -60,7 +60,7 @@ public function getTitle() return $this->title; } - public function setTitle($title) + public function setTitle($title): void { $this->title = $title; } @@ -70,7 +70,7 @@ public function getName() return $this->name; } - public function setName($name) + public function setName($name): void { $this->name = $name; } @@ -80,7 +80,7 @@ public function getEmail() return $this->email; } - public function setEmail($email) + public function setEmail($email): void { $this->email = $email; } @@ -90,7 +90,7 @@ public function getEmailSubject() return $this->emailSubject; } - public function setEmailSubject($emailSubject) + public function setEmailSubject($emailSubject): void { $this->emailSubject = $emailSubject; } @@ -100,7 +100,7 @@ public function getEmailText() return $this->emailText; } - public function setEmailText($emailText) + public function setEmailText($emailText): void { $this->emailText = $emailText; } @@ -110,7 +110,7 @@ public function getSuccessUrl() return $this->successUrl; } - public function setSuccessUrl($url) + public function setSuccessUrl($url): void { $this->successUrl = $url; } @@ -120,7 +120,7 @@ public function getReminderInterval() return $this->reminderInterval; } - public function setReminderInterval($interval) + public function setReminderInterval($interval): void { $this->reminderInterval = $interval; } @@ -130,12 +130,12 @@ public function getCustomText() return $this->customText; } - public function setCustomText($text) + public function setCustomText($text): void { $this->customText = $text; } - public function getStatus() + public function getStatus(): string { switch ($this->status) { case 0: diff --git a/src/WebhookSubscription.php b/src/WebhookSubscription.php index c9141e6..ee10713 100644 --- a/src/WebhookSubscription.php +++ b/src/WebhookSubscription.php @@ -112,6 +112,6 @@ public function setEndpoint(string $endpoint): static public static function test(): bool { - return ApiConnector::callServer(self::$relativeUrl . '/test', null, 'post', array()) !== null; + return ApiConnector::callServer(self::$relativeUrl . '/test', null, 'post', []) !== null; } } diff --git a/tests/unit/OAuth/OAuthApiTest.php b/tests/unit/OAuth/OAuthApiTest.php index 7e89e79..38a2628 100644 --- a/tests/unit/OAuth/OAuthApiTest.php +++ b/tests/unit/OAuth/OAuthApiTest.php @@ -69,6 +69,9 @@ function () { public function testAPICallsUseCorrectHostname(string $env, string $expected, string $method, array $params = []) { $this->config->method('getEnvironment')->willReturn($env); + if ($method === 'postApiKeyExchange') { + $this->config->method('getApiSecret')->willReturn('apiSecret'); + } $this->client->expects($this->once()) ->method('post')