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')