初始化仓库

This commit is contained in:
wangxiaowei
2025-04-22 14:09:52 +08:00
commit 8b100110bb
5155 changed files with 664201 additions and 0 deletions

View File

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel;
use ArrayAccess;
use EasyWeChat\Kernel\Contracts\Config as ConfigInterface;
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
use EasyWeChat\Kernel\Support\Arr;
use JetBrains\PhpStorm\Pure;
use function strval;
/**
* @implements ArrayAccess<mixed, mixed>
*/
class Config implements ArrayAccess, ConfigInterface
{
/**
* @var array<string>
*/
protected array $requiredKeys = [];
/**
* @param array<string, mixed> $items
*
* @throws InvalidArgumentException
*/
public function __construct(
protected array $items = [],
) {
$this->checkMissingKeys();
}
#[Pure]
public function has(string $key): bool
{
return Arr::has($this->items, $key);
}
/**
* @param array<string>|string $key
*/
#[Pure]
public function get(array|string $key, mixed $default = null): mixed
{
if (is_array($key)) {
return $this->getMany($key);
}
return Arr::get($this->items, $key, $default);
}
/**
* @param array<string> $keys
* @return array<string, mixed>
*/
#[Pure]
public function getMany(array $keys): array
{
$config = [];
foreach ($keys as $key => $default) {
if (is_numeric($key)) {
[$key, $default] = [$default, null];
}
$config[$key] = Arr::get($this->items, $key, $default);
}
return $config;
}
/**
* @param string $key
* @param mixed|null $value
*/
public function set(string $key, mixed $value = null): void
{
Arr::set($this->items, $key, $value);
}
/**
* @return array<string, mixed>
*/
public function all(): array
{
return $this->items;
}
#[Pure]
public function offsetExists(mixed $key): bool
{
return $this->has(strval($key));
}
#[Pure]
public function offsetGet(mixed $key): mixed
{
return $this->get(strval($key));
}
public function offsetSet(mixed $key, mixed $value): void
{
$this->set(strval($key), $value);
}
public function offsetUnset(mixed $key): void
{
$this->set(strval($key), null);
}
/**
* @throws InvalidArgumentException
*/
public function checkMissingKeys(): bool
{
if (empty($this->requiredKeys)) {
return true;
}
$missingKeys = [];
foreach ($this->requiredKeys as $key) {
if (! $this->has($key)) {
$missingKeys[] = $key;
}
}
if (! empty($missingKeys)) {
throw new InvalidArgumentException(sprintf("\"%s\" cannot be empty.\r\n", implode(',', $missingKeys)));
}
return true;
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Contracts;
interface AccessToken
{
public function getToken(): string;
/**
* @return array<string,string>
*/
public function toQuery(): array;
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Contracts;
use EasyWeChat\Kernel\Contracts\AccessToken as AccessTokenInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
interface AccessTokenAwareHttpClient extends HttpClientInterface
{
public function withAccessToken(AccessTokenInterface $accessToken): static;
}

View File

@ -0,0 +1,10 @@
<?php
namespace EasyWeChat\Kernel\Contracts;
interface Aes
{
public static function encrypt(string $plaintext, string $key, string $iv = null): string;
public static function decrypt(string $ciphertext, string $key, string $iv = null): string;
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Contracts;
interface Arrayable
{
/**
* @return array<int|string, mixed>
*/
public function toArray(): array;
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Contracts;
use ArrayAccess;
/**
* @extends ArrayAccess<string, mixed>
*/
interface Config extends ArrayAccess
{
/**
* @return array<string,mixed>
*/
public function all(): array;
public function has(string $key): bool;
public function set(string $key, mixed $value = null): void;
/**
* @param array<string>|string $key
*/
public function get(array|string $key, mixed $default = null): mixed;
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Contracts;
interface Jsonable
{
public function toJson(): string|false;
}

View File

@ -0,0 +1,8 @@
<?php
namespace EasyWeChat\Kernel\Contracts;
interface RefreshableAccessToken extends AccessToken
{
public function refresh(): string;
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Contracts;
use Psr\Http\Message\ResponseInterface;
interface Server
{
public function serve(): ResponseInterface;
}

View File

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel;
use function base64_decode;
use function base64_encode;
use EasyWeChat\Kernel\Exceptions\RuntimeException;
use EasyWeChat\Kernel\Support\Pkcs7;
use EasyWeChat\Kernel\Support\Str;
use EasyWeChat\Kernel\Support\Xml;
use Exception;
use function implode;
use function openssl_decrypt;
use function openssl_encrypt;
use const OPENSSL_NO_PADDING;
use function pack;
use function random_bytes;
use function sha1;
use function sort;
use const SORT_STRING;
use function strlen;
use function substr;
use Throwable;
use function time;
use function trim;
use function unpack;
class Encryptor
{
public const ERROR_INVALID_SIGNATURE = -40001; // Signature verification failed
public const ERROR_PARSE_XML = -40002; // Parse XML failed
public const ERROR_CALC_SIGNATURE = -40003; // Calculating the signature failed
public const ERROR_INVALID_AES_KEY = -40004; // Invalid AESKey
public const ERROR_INVALID_APP_ID = -40005; // Check AppID failed
public const ERROR_ENCRYPT_AES = -40006; // AES EncryptionInterface failed
public const ERROR_DECRYPT_AES = -40007; // AES decryption failed
public const ERROR_INVALID_XML = -40008; // Invalid XML
public const ERROR_BASE64_ENCODE = -40009; // Base64 encoding failed
public const ERROR_BASE64_DECODE = -40010; // Base64 decoding failed
public const ERROR_XML_BUILD = -40011; // XML build failed
public const ILLEGAL_BUFFER = -41003; // Illegal buffer
protected string $appId;
protected string $token;
protected string $aesKey;
protected int $blockSize = 32;
protected ?string $receiveId = null;
public function __construct(string $appId, string $token, string $aesKey, ?string $receiveId = null)
{
$this->appId = $appId;
$this->token = $token;
$this->receiveId = $receiveId;
$this->aesKey = base64_decode($aesKey.'=', true) ?: '';
}
public function getToken(): string
{
return $this->token;
}
/**
* @throws RuntimeException
* @throws Exception
*/
public function encrypt(string $plaintext, string|null $nonce = null, int|string $timestamp = null): string
{
try {
$plaintext = Pkcs7::padding(random_bytes(16).pack('N', strlen($plaintext)).$plaintext.$this->appId, 32);
$ciphertext = base64_encode(
openssl_encrypt(
$plaintext,
'aes-256-cbc',
$this->aesKey,
OPENSSL_NO_PADDING,
substr($this->aesKey, 0, 16)
) ?: ''
);
} catch (Throwable $e) {
throw new RuntimeException($e->getMessage(), self::ERROR_ENCRYPT_AES);
}
$nonce ??= Str::random();
$timestamp ??= time();
$response = [
'Encrypt' => $ciphertext,
'MsgSignature' => $this->createSignature($this->token, $timestamp, $nonce, $ciphertext),
'TimeStamp' => $timestamp,
'Nonce' => $nonce,
];
return Xml::build($response);
}
public function createSignature(mixed ...$attributes): string
{
sort($attributes, SORT_STRING);
return sha1(implode($attributes));
}
/**
* @throws RuntimeException
*/
public function decrypt(string $ciphertext, string $msgSignature, string $nonce, int|string $timestamp): string
{
$signature = $this->createSignature($this->token, $timestamp, $nonce, $ciphertext);
if ($signature !== $msgSignature) {
throw new RuntimeException('Invalid Signature.', self::ERROR_INVALID_SIGNATURE);
}
$plaintext = Pkcs7::unpadding(
openssl_decrypt(
base64_decode($ciphertext, true) ?: '',
'aes-256-cbc',
$this->aesKey,
OPENSSL_NO_PADDING,
substr($this->aesKey, 0, 16)
) ?: '',
32
);
$plaintext = substr($plaintext, 16);
$contentLength = (unpack('N', substr($plaintext, 0, 4)) ?: [])[1];
if ($this->receiveId && trim(substr($plaintext, $contentLength + 4)) !== $this->receiveId) {
throw new RuntimeException('Invalid appId.', self::ERROR_INVALID_APP_ID);
}
return substr($plaintext, 4, $contentLength);
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace EasyWeChat\Kernel\Exceptions;
class BadMethodCallException extends Exception
{
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Exceptions;
class BadRequestException extends Exception
{
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Exceptions;
class BadResponseException extends Exception
{
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Exceptions;
class DecryptException extends Exception
{
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Exceptions;
use Exception as BaseException;
class Exception extends BaseException
{
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Exceptions;
use Psr\Http\Message\ResponseInterface;
class HttpException extends Exception
{
public ?ResponseInterface $response;
/**
* HttpException constructor.
*
* @param string $message
* @param ResponseInterface|null $response
* @param int $code
*/
public function __construct(string $message, ResponseInterface $response = null, int $code = 0)
{
parent::__construct($message, $code);
$this->response = $response;
if ($response) {
$response->getBody()->rewind();
}
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Exceptions;
class InvalidArgumentException extends Exception
{
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Exceptions;
class InvalidConfigException extends Exception
{
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Exceptions;
class RuntimeException extends Exception
{
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Exceptions;
class ServiceNotFoundException extends Exception
{
}

View File

@ -0,0 +1,47 @@
<?php
namespace EasyWeChat\Kernel\Form;
use EasyWeChat\Kernel\Exceptions\RuntimeException;
use function file_put_contents;
use function md5;
use function pathinfo;
use const PATHINFO_EXTENSION;
use function strtolower;
use Symfony\Component\Mime\MimeTypes;
use Symfony\Component\Mime\Part\DataPart;
use function sys_get_temp_dir;
use function tempnam;
class File extends DataPart
{
/**
* @throws RuntimeException
*/
public static function withContents(
string $contents,
?string $filename = null,
?string $contentType = null,
?string $encoding = null
): DataPart {
if (null === $contentType) {
$mimeTypes = new MimeTypes();
if ($filename) {
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$contentType = $mimeTypes->getMimeTypes($ext)[0] ?? 'application/octet-stream';
} else {
$tmp = tempnam(sys_get_temp_dir(), 'easywechat');
if (! $tmp) {
throw new RuntimeException('Failed to create temporary file.');
}
file_put_contents($tmp, $contents);
$contentType = $mimeTypes->guessMimeType($tmp) ?? 'application/octet-stream';
$filename = md5($contents).'.'.($mimeTypes->getExtensions($contentType)[0] ?? null);
}
}
return new self($contents, $filename, $contentType, $encoding);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace EasyWeChat\Kernel\Form;
use JetBrains\PhpStorm\ArrayShape;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
class Form
{
/**
* @param array<string|array|DataPart> $fields
*/
public function __construct(protected array $fields)
{
}
/**
* @param array<string|array|DataPart> $fields
*/
public static function create(array $fields): Form
{
return new self($fields);
}
/**
* @return array<string,mixed>
*/
#[ArrayShape(['headers' => 'array', 'body' => 'string'])]
public function toArray(): array
{
return $this->toOptions();
}
/**
* @return array<string,mixed>
*/
#[ArrayShape(['headers' => 'array', 'body' => 'string'])]
public function toOptions(): array
{
$formData = new FormDataPart($this->fields);
return [
'headers' => $formData->getPreparedHeaders()->toArray(),
'body' => $formData->bodyToString(),
];
}
}

View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\HttpClient;
use function array_merge;
use Closure;
use EasyWeChat\Kernel\Contracts\AccessToken as AccessTokenInterface;
use EasyWeChat\Kernel\Contracts\AccessTokenAwareHttpClient as AccessTokenAwareHttpClientInterface;
use EasyWeChat\Kernel\Traits\MockableHttpClient;
use Symfony\Component\HttpClient\AsyncDecoratorTrait;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Class AccessTokenAwareClient.
*
*
* @method HttpClientInterface withAppId(string $value = null)
*/
class AccessTokenAwareClient implements AccessTokenAwareHttpClientInterface
{
use AsyncDecoratorTrait;
use HttpClientMethods;
use RetryableClient;
use MockableHttpClient;
use RequestWithPresets;
public function __construct(
?HttpClientInterface $client = null,
protected ?AccessTokenInterface $accessToken = null,
protected ?Closure $failureJudge = null,
protected bool $throw = true
) {
$this->client = $client ?? HttpClient::create();
}
public function withAccessToken(AccessTokenInterface $accessToken): static
{
$this->accessToken = $accessToken;
return $this;
}
/**
* @param array<string, mixed> $options
*
* @throws TransportExceptionInterface
*/
public function request(string $method, string $url, array $options = []): Response
{
if ($this->accessToken) {
$options['query'] = array_merge((array) ($options['query'] ?? []), $this->accessToken->toQuery());
}
$options = RequestUtil::formatBody($this->mergeThenResetPrepends($options));
return new Response(
response: $this->client->request($method, ltrim($url, '/'), $options),
failureJudge: $this->failureJudge,
throw: $this->throw
);
}
/**
* @param array<int, mixed> $arguments
*/
public function __call(string $name, array $arguments): mixed
{
if (\str_starts_with($name, 'with')) {
return $this->handleMagicWithCall($name, $arguments[0] ?? null);
}
return $this->client->$name(...$arguments);
}
public static function createMockClient(MockHttpClient $mockHttpClient): HttpClientInterface
{
return new self($mockHttpClient);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace EasyWeChat\Kernel\HttpClient;
use Closure;
use EasyWeChat\Kernel\Contracts\AccessToken as AccessTokenInterface;
use EasyWeChat\Kernel\Contracts\RefreshableAccessToken as RefreshableAccessTokenInterface;
use Symfony\Component\HttpClient\Response\AsyncContext;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
class AccessTokenExpiredRetryStrategy extends GenericRetryStrategy
{
protected AccessTokenInterface $accessToken;
protected ?Closure $decider = null;
public function withAccessToken(AccessTokenInterface $accessToken): self
{
$this->accessToken = $accessToken;
return $this;
}
public function decideUsing(Closure $decider): self
{
$this->decider = $decider;
return $this;
}
public function shouldRetry(
AsyncContext $context,
?string $responseContent,
?TransportExceptionInterface $exception
): ?bool {
if ((bool) $responseContent && $this->decider && ($this->decider)($context, $responseContent, $exception)) {
if ($this->accessToken instanceof RefreshableAccessTokenInterface) {
return (bool) $this->accessToken->refresh();
}
return false;
}
return parent::shouldRetry($context, $responseContent, $exception);
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace EasyWeChat\Kernel\HttpClient;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface as ResponseInterfaceAlias;
trait HttpClientMethods
{
/**
* @param string $url
* @param array<string, mixed> $options
* @return Response|ResponseInterfaceAlias
*
* @throws TransportExceptionInterface
*/
public function get(string $url, array $options = []): Response|ResponseInterfaceAlias
{
return $this->request('GET', $url, RequestUtil::formatOptions($options, 'GET'));
}
/**
* @param array<string, mixed> $options
*
* @throws TransportExceptionInterface
*/
public function post(string $url, array $options = []): Response|ResponseInterfaceAlias
{
return $this->request('POST', $url, RequestUtil::formatOptions($options, 'POST'));
}
/**
* @throws TransportExceptionInterface
*/
public function postJson(string $url, array $options = []): Response|ResponseInterfaceAlias
{
$options['headers']['Content-Type'] = 'application/json';
return $this->request('POST', $url, RequestUtil::formatOptions($options, 'POST'));
}
/**
* @throws TransportExceptionInterface
*/
public function postXml(string $url, array $options = []): Response|ResponseInterfaceAlias
{
$options['headers']['Content-Type'] = 'text/xml';
return $this->request('POST', $url, RequestUtil::formatOptions($options, 'POST'));
}
/**
* @param array<string, mixed> $options
*
* @throws TransportExceptionInterface
*/
public function patch(string $url, array $options = []): Response|ResponseInterfaceAlias
{
return $this->request('PATCH', $url, RequestUtil::formatOptions($options, 'PATCH'));
}
/**
* @throws TransportExceptionInterface
*/
public function patchJson(string $url, array $options = []): Response|ResponseInterfaceAlias
{
$options['headers']['Content-Type'] = 'application/json';
return $this->request('PATCH', $url, RequestUtil::formatOptions($options, 'PATCH'));
}
/**
* @param array<string, mixed> $options
*
* @throws TransportExceptionInterface
*/
public function put(string $url, array $options = []): Response|ResponseInterfaceAlias
{
return $this->request('PUT', $url, RequestUtil::formatOptions($options, 'PUT'));
}
/**
* @param array<string, mixed> $options
*
* @throws TransportExceptionInterface
*/
public function delete(string $url, array $options = []): Response|ResponseInterfaceAlias
{
return $this->request('DELETE', $url, RequestUtil::formatOptions($options, 'DELETE'));
}
}

View File

@ -0,0 +1,163 @@
<?php
namespace EasyWeChat\Kernel\HttpClient;
use const ARRAY_FILTER_USE_KEY;
use function array_key_exists;
use EasyWeChat\Kernel\Support\UserAgent;
use EasyWeChat\Kernel\Support\Xml;
use function in_array;
use InvalidArgumentException;
use function is_array;
use function is_string;
use JetBrains\PhpStorm\ArrayShape;
use function json_encode;
use const JSON_FORCE_OBJECT;
use const JSON_UNESCAPED_UNICODE;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class RequestUtil
{
/**
* @param array<string, mixed> $options
* @return array<string, mixed>
*/
#[ArrayShape([
'status_codes' => 'array',
'delay' => 'int',
'max_delay' => 'int',
'max_retries' => 'int',
'multiplier' => 'float',
'jitter' => 'float',
])]
public static function mergeDefaultRetryOptions(array $options): array
{
return \array_merge([
'status_codes' => GenericRetryStrategy::DEFAULT_RETRY_STATUS_CODES,
'delay' => 1000,
'max_delay' => 0,
'max_retries' => 3,
'multiplier' => 2.0,
'jitter' => 0.1,
], $options);
}
/**
* @param array<string, array|mixed> $options
* @return array<string, array|mixed>
*/
public static function formatDefaultOptions(array $options): array
{
$defaultOptions = \array_filter(
array: $options,
callback: fn ($key) => array_key_exists($key, HttpClientInterface::OPTIONS_DEFAULTS),
mode: ARRAY_FILTER_USE_KEY
);
/** @phpstan-ignore-next-line */
if (! isset($options['headers']['User-Agent']) && ! isset($options['headers']['user-agent'])) {
/** @phpstan-ignore-next-line */
$defaultOptions['headers']['User-Agent'] = UserAgent::create();
}
return $defaultOptions;
}
public static function formatOptions(array $options, string $method): array
{
if (array_key_exists('query', $options) && is_array($options['query']) && empty($options['query'])) {
return $options;
}
if (array_key_exists('body', $options)
|| array_key_exists('json', $options)
|| array_key_exists('xml', $options)
) {
return $options;
}
$name = in_array($method, ['GET', 'HEAD', 'DELETE']) ? 'query' : 'body';
if (($options['headers']['Content-Type'] ?? $options['headers']['content-type'] ?? null) === 'application/json') {
$name = 'json';
}
foreach ($options as $key => $value) {
if (! array_key_exists($key, HttpClientInterface::OPTIONS_DEFAULTS)) {
$options[$name][trim($key, '"')] = $value;
unset($options[$key]);
}
}
return $options;
}
/**
* @param array<string, array<string,mixed>|mixed> $options
* @return array<string, array|mixed>
*/
public static function formatBody(array $options): array
{
if (isset($options['xml'])) {
if (is_array($options['xml'])) {
$options['xml'] = Xml::build($options['xml']);
}
if (! is_string($options['xml'])) {
throw new InvalidArgumentException('The type of `xml` must be string or array.');
}
/** @phpstan-ignore-next-line */
if (! isset($options['headers']['Content-Type']) && ! isset($options['headers']['content-type'])) {
/** @phpstan-ignore-next-line */
$options['headers']['Content-Type'] = [$options['headers'][] = 'Content-Type: text/xml'];
}
$options['body'] = $options['xml'];
unset($options['xml']);
}
if (isset($options['json'])) {
if (is_array($options['json'])) {
/** XXX: 微信的 JSON 是比较奇葩的,比如菜单不能把中文 encode 为 unicode */
$options['json'] = json_encode(
$options['json'],
empty($options['json']) ? JSON_FORCE_OBJECT : JSON_UNESCAPED_UNICODE
);
}
if (! is_string($options['json'])) {
throw new InvalidArgumentException('The type of `json` must be string or array.');
}
/** @phpstan-ignore-next-line */
if (! isset($options['headers']['Content-Type']) && ! isset($options['headers']['content-type'])) {
/** @phpstan-ignore-next-line */
$options['headers']['Content-Type'] = [$options['headers'][] = 'Content-Type: application/json'];
}
$options['body'] = $options['json'];
unset($options['json']);
}
return $options;
}
public static function createDefaultServerRequest(): ServerRequestInterface
{
$psr17Factory = new Psr17Factory();
$creator = new ServerRequestCreator(
serverRequestFactory: $psr17Factory,
uriFactory: $psr17Factory,
uploadedFileFactory: $psr17Factory,
streamFactory: $psr17Factory
);
return $creator->fromGlobals();
}
}

View File

@ -0,0 +1,180 @@
<?php
namespace EasyWeChat\Kernel\HttpClient;
use function array_merge;
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
use EasyWeChat\Kernel\Exceptions\RuntimeException;
use EasyWeChat\Kernel\Form\File;
use EasyWeChat\Kernel\Form\Form;
use EasyWeChat\Kernel\Support\Str;
use function in_array;
use function is_file;
use function is_string;
use function str_ends_with;
use function str_starts_with;
use function strtoupper;
use function substr;
trait RequestWithPresets
{
/**
* @var array<string, string>
*/
protected array $prependHeaders = [];
/**
* @var array<string, mixed>
*/
protected array $prependParts = [];
/**
* @var array<string, mixed>
*/
protected array $presets = [];
/**
* @param array<string, mixed> $presets
*/
public function setPresets(array $presets): static
{
$this->presets = $presets;
return $this;
}
public function withHeader(string $key, string $value): static
{
$this->prependHeaders[$key] = $value;
return $this;
}
public function withHeaders(array $headers): static
{
foreach ($headers as $key => $value) {
$this->withHeader($key, $value);
}
return $this;
}
/**
* @throws InvalidArgumentException
*/
public function with(string|array $key, mixed $value = null): static
{
if (\is_array($key)) {
// $client->with(['appid', 'mchid'])
// $client->with(['appid' => 'wx1234567', 'mchid'])
foreach ($key as $k => $v) {
if (\is_int($k) && is_string($v)) {
[$k, $v] = [$v, null];
}
$this->with($k, $v ?? $this->presets[$k] ?? null);
}
return $this;
}
$this->prependParts[$key] = $value ?? $this->presets[$key] ?? null;
return $this;
}
/**
* @throws RuntimeException
* @throws InvalidArgumentException
*/
public function withFile(string $pathOrContents, string $formName = 'file', string $filename = null): static
{
$file = is_file($pathOrContents) ? File::fromPath(
$pathOrContents,
$filename
) : File::withContents($pathOrContents, $filename);
/**
* @var array{headers: array<string, string>, body: string}
*/
$options = Form::create([$formName => $file])->toOptions();
$this->withHeaders($options['headers']);
return $this->withOptions([
'body' => $options['body'],
]);
}
/**
* @throws RuntimeException
* @throws InvalidArgumentException
*/
public function withFileContents(string $contents, string $formName = 'file', string $filename = null): static
{
return $this->withFile($contents, $formName, $filename);
}
/**
* @throws RuntimeException
* @throws InvalidArgumentException
*/
public function withFiles(array $files): static
{
foreach ($files as $key => $value) {
$this->withFile($value, $key);
}
return $this;
}
public function mergeThenResetPrepends(array $options, string $method = 'GET'): array
{
$name = in_array(strtoupper($method), ['GET', 'HEAD', 'DELETE']) ? 'query' : 'body';
if (($options['headers']['Content-Type'] ?? $options['headers']['content-type'] ?? null) === 'application/json' || ! empty($options['json'])) {
$name = 'json';
}
if (($options['headers']['Content-Type'] ?? $options['headers']['content-type'] ?? null) === 'text/xml' || ! empty($options['xml'])) {
$name = 'xml';
}
if (! empty($this->prependParts)) {
$options[$name] = array_merge($this->prependParts, $options[$name] ?? []);
}
if (! empty($this->prependHeaders)) {
$options['headers'] = array_merge($this->prependHeaders, $options['headers'] ?? []);
}
$this->prependParts = [];
$this->prependHeaders = [];
return $options;
}
/**
* @throws InvalidArgumentException
*/
public function handleMagicWithCall(string $method, mixed $value = null): static
{
// $client->withAppid();
// $client->withAppid('wxf8b4f85f3a794e77');
// $client->withAppidAs('sub_appid');
if (! str_starts_with($method, 'with')) {
throw new InvalidArgumentException(sprintf('The method "%s" is not supported.', $method));
}
$key = Str::snakeCase(substr($method, 4));
// $client->withAppidAs('sub_appid');
if (str_ends_with($key, '_as')) {
$key = substr($key, 0, -3);
[$key, $value] = [is_string($value) ? $value : $key, $this->presets[$key] ?? null];
}
return $this->with($key, $value);
}
}

View File

@ -0,0 +1,388 @@
<?php
namespace EasyWeChat\Kernel\HttpClient;
use function array_key_exists;
use ArrayAccess;
use function base64_encode;
use Closure;
use EasyWeChat\Kernel\Contracts\Arrayable;
use EasyWeChat\Kernel\Contracts\Jsonable;
use EasyWeChat\Kernel\Exceptions\BadMethodCallException;
use EasyWeChat\Kernel\Exceptions\BadResponseException;
use EasyWeChat\Kernel\Support\Xml;
use function file_put_contents;
use Http\Discovery\Exception\NotFoundException;
use Http\Discovery\Psr17FactoryDiscovery;
use function json_encode;
use const JSON_UNESCAPED_UNICODE;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use function sprintf;
use function str_contains;
use function str_starts_with;
use function strtolower;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpClient\Response\StreamableInterface;
use Symfony\Component\HttpClient\Response\StreamWrapper;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Throwable;
/**
* @implements \ArrayAccess<array-key, mixed>
*
* @see \Symfony\Contracts\HttpClient\ResponseInterface
*/
class Response implements Jsonable, Arrayable, ArrayAccess, ResponseInterface, StreamableInterface
{
public function __construct(
protected ResponseInterface $response,
protected ?Closure $failureJudge = null,
protected bool $throw = true
) {
}
public function throw(bool $throw = true): static
{
$this->throw = $throw;
return $this;
}
public function throwOnFailure(): static
{
return $this->throw(true);
}
public function quietly(): static
{
return $this->throw(false);
}
public function judgeFailureUsing(callable $callback): static
{
$this->failureJudge = $callback instanceof Closure ? $callback : fn (Response $response) => $callback($response);
return $this;
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
*/
public function isSuccessful(): bool
{
return ! $this->isFailed();
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
*/
public function isFailed(): bool
{
if ($this->is('text') && $this->failureJudge) {
return (bool) ($this->failureJudge)($this);
}
try {
return 400 <= $this->getStatusCode();
} catch (Throwable $e) {
return true;
}
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
* @throws BadResponseException
*/
public function toArray(?bool $throw = null): array
{
$throw ??= $this->throw;
if ('' === $content = $this->response->getContent($throw)) {
throw new BadResponseException('Response body is empty.');
}
$contentType = $this->getHeaderLine('content-type', $throw);
if (str_contains($contentType, 'text/xml')
|| str_contains($contentType, 'application/xml')
|| str_starts_with($content, '<xml>')) {
try {
return Xml::parse($content) ?? [];
} catch (Throwable $e) {
throw new BadResponseException('Response body is not valid xml.', 400, $e);
}
}
return $this->response->toArray($throw);
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
* @throws BadResponseException
*/
public function toJson(?bool $throw = null): string|false
{
return json_encode($this->toArray($throw), JSON_UNESCAPED_UNICODE);
}
/**
* {@inheritdoc}
*/
public function toStream(?bool $throw = null)
{
if ($this->response instanceof StreamableInterface) {
return $this->response->toStream($throw ?? $this->throw);
}
if ($throw) {
throw new BadMethodCallException(sprintf('%s does\'t implements %s', \get_class($this->response), StreamableInterface::class));
}
return StreamWrapper::createResource(new MockResponse());
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
*/
public function toDataUrl(): string
{
return 'data:'.$this->getHeaderLine('content-type').';base64,'.base64_encode($this->getContent());
}
public function toPsrResponse(ResponseFactoryInterface $responseFactory = null, StreamFactoryInterface $streamFactory = null): \Psr\Http\Message\ResponseInterface
{
$streamFactory ??= $responseFactory instanceof StreamFactoryInterface ? $responseFactory : null;
if (null === $responseFactory || null === $streamFactory) {
if (! class_exists(Psr17Factory::class) && ! class_exists(Psr17FactoryDiscovery::class)) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\Psr18Client" as no PSR-17 factories have been provided. Try running "composer require nyholm/psr7".');
}
try {
$psr17Factory = class_exists(Psr17Factory::class, false) ? new Psr17Factory() : null;
$responseFactory ??= $psr17Factory ?? Psr17FactoryDiscovery::findResponseFactory(); /** @phpstan-ignore-line */
$streamFactory ??= $psr17Factory ?? Psr17FactoryDiscovery::findStreamFactory(); /** @phpstan-ignore-line */
/** @phpstan-ignore-next-line */
} catch (NotFoundException $e) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\HttplugClient" as no PSR-17 factories have been found. Try running "composer require nyholm/psr7".', 0, $e);
}
}
$psrResponse = $responseFactory->createResponse($this->getStatusCode());
foreach ($this->getHeaders(false) as $name => $values) {
foreach ($values as $value) {
$psrResponse = $psrResponse->withAddedHeader($name, $value);
}
}
$body = $this->response instanceof StreamableInterface ? $this->toStream(false) : StreamWrapper::createResource($this->response);
$body = $streamFactory->createStreamFromResource($body);
if ($body->isSeekable()) {
$body->seek(0);
}
return $psrResponse->withBody($body);
}
/**
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
* @throws BadResponseException
*/
public function saveAs(string $filename): string
{
try {
file_put_contents($filename, $this->response->getContent(true));
} catch (Throwable $e) {
throw new BadResponseException(sprintf(
'Cannot save response to %s: %s',
$filename,
$this->response->getContent(false)
), $e->getCode(), $e);
}
return '';
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
* @throws BadResponseException
*/
public function offsetExists(mixed $offset): bool
{
return array_key_exists($offset, $this->toArray());
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
* @throws BadResponseException
*/
public function offsetGet(mixed $offset): mixed
{
return $this->toArray()[$offset] ?? null;
}
/**
* @throws BadMethodCallException
*/
public function offsetSet(mixed $offset, mixed $value): void
{
throw new BadMethodCallException('Response is immutable.');
}
/**
* @throws BadMethodCallException
*/
public function offsetUnset(mixed $offset): void
{
throw new BadMethodCallException('Response is immutable.');
}
/**
* @param array<array-key, mixed> $arguments
*/
public function __call(string $name, array $arguments): mixed
{
return $this->response->{$name}(...$arguments);
}
public function getStatusCode(): int
{
return $this->response->getStatusCode();
}
public function getHeaders(?bool $throw = null): array
{
return $this->response->getHeaders($throw ?? $this->throw);
}
public function getContent(?bool $throw = null): string
{
return $this->response->getContent($throw ?? $this->throw);
}
public function cancel(): void
{
$this->response->cancel();
}
public function getInfo(string $type = null): mixed
{
return $this->response->getInfo($type);
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
* @throws BadResponseException
*/
public function __toString(): string
{
return $this->toJson() ?: '';
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
*/
public function hasHeader(string $name, ?bool $throw = null): bool
{
return isset($this->getHeaders($throw)[$name]);
}
/**
* @return array<array-key, mixed>
*
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
*/
public function getHeader(string $name, ?bool $throw = null): array
{
$name = strtolower($name);
$throw ??= $this->throw;
return $this->hasHeader($name, $throw) ? $this->getHeaders($throw)[$name] : [];
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
*/
public function getHeaderLine(string $name, ?bool $throw = null): string
{
$name = strtolower($name);
$throw ??= $this->throw;
return $this->hasHeader($name, $throw) ? implode(',', $this->getHeader($name, $throw)) : '';
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
*/
public function is(string $type): bool
{
$contentType = $this->getHeaderLine('content-type');
return match (strtolower($type)) {
'json' => str_contains($contentType, '/json'),
'xml' => str_contains($contentType, '/xml'),
'html' => str_contains($contentType, '/html'),
'image' => str_contains($contentType, 'image/'),
'audio' => str_contains($contentType, 'audio/'),
'video' => str_contains($contentType, 'video/'),
'text' => str_contains($contentType, 'text/')
|| str_contains($contentType, '/json')
|| str_contains($contentType, '/xml'),
default => false,
};
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace EasyWeChat\Kernel\HttpClient;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
use Symfony\Component\HttpClient\Retry\RetryStrategyInterface;
use Symfony\Component\HttpClient\RetryableHttpClient;
trait RetryableClient
{
/**
* @param array<string, mixed> $config
*/
public function retry(array $config = []): static
{
$config = RequestUtil::mergeDefaultRetryOptions($config);
$strategy = new GenericRetryStrategy(
// @phpstan-ignore-next-line
(array) $config['status_codes'],
// @phpstan-ignore-next-line
(int) $config['delay'],
// @phpstan-ignore-next-line
(float) $config['multiplier'],
// @phpstan-ignore-next-line
(int) $config['max_delay'],
// @phpstan-ignore-next-line
(float) $config['jitter']
);
/** @phpstan-ignore-next-line */
return $this->retryUsing($strategy, (int) $config['max_retries']);
}
public function retryUsing(
RetryStrategyInterface $strategy,
int $maxRetries = 3,
LoggerInterface $logger = null
): static {
$this->client = new RetryableHttpClient($this->client, $strategy, $maxRetries, $logger);
return $this;
}
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel;
use ArrayAccess;
use EasyWeChat\Kernel\Exceptions\BadRequestException;
use EasyWeChat\Kernel\Support\Xml;
use EasyWeChat\Kernel\Traits\HasAttributes;
use Psr\Http\Message\ServerRequestInterface;
/**
* @property string $FromUserName
* @property string $ToUserName
* @property string $Encrypt
* @implements ArrayAccess<array-key, mixed>
*/
abstract class Message implements ArrayAccess
{
use HasAttributes;
/**
* @param array<string,string> $attributes
*/
final public function __construct(array $attributes = [], protected ?string $originContent = '')
{
$this->attributes = $attributes;
}
/**
* @param ServerRequestInterface $request
* @return Message
*
* @throws BadRequestException
*/
public static function createFromRequest(ServerRequestInterface $request): Message
{
$attributes = self::format($originContent = strval($request->getBody()));
return new static($attributes, $originContent);
}
/**
* @return array<string,string>
*
* @throws BadRequestException
*/
public static function format(string $originContent): array
{
if (0 === stripos($originContent, '<')) {
$attributes = Xml::parse($originContent);
}
// Handle JSON format.
$dataSet = json_decode($originContent, true);
if (JSON_ERROR_NONE === json_last_error() && $originContent) {
$attributes = $dataSet;
}
if (empty($attributes) || ! is_array($attributes)) {
throw new BadRequestException('Failed to decode request contents.');
}
return $attributes;
}
public function getOriginalContents(): string
{
return $this->originContent ?? '';
}
public function __toString()
{
return $this->toJson() ?: '';
}
}

View File

@ -0,0 +1,211 @@
<?php
namespace EasyWeChat\Kernel;
use function array_keys;
use function array_map;
use function count;
use function header;
use JetBrains\PhpStorm\Pure;
use function max;
use const PHP_OUTPUT_HANDLER_CLEANABLE;
use const PHP_OUTPUT_HANDLER_FLUSHABLE;
use const PHP_OUTPUT_HANDLER_REMOVABLE;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use function sprintf;
use function ucwords;
class ServerResponse implements ResponseInterface
{
public function __construct(protected ResponseInterface $response)
{
$this->response->getBody()->rewind();
}
#[Pure]
public static function make(ResponseInterface $response): ServerResponse
{
if ($response instanceof ServerResponse) {
return $response;
}
return new self($response);
}
public function getProtocolVersion(): string
{
return $this->response->getProtocolVersion();
}
public function withProtocolVersion($version): ServerResponse|ResponseInterface
{
return $this->response->withProtocolVersion($version);
}
public function getHeaders(): array
{
return $this->response->getHeaders();
}
public function hasHeader($name): bool
{
return $this->response->hasHeader($name);
}
public function getHeader($name): array
{
return $this->response->getHeader($name);
}
public function getHeaderLine($name): string
{
return $this->response->getHeaderLine($name);
}
public function withHeader($name, $value): ServerResponse|ResponseInterface
{
return $this->response->withHeader($name, $value);
}
public function withAddedHeader($name, $value): ServerResponse|ResponseInterface
{
return $this->response->withAddedHeader($name, $value);
}
public function withoutHeader($name): ServerResponse|ResponseInterface
{
return $this->response->withoutHeader($name);
}
public function getBody(): StreamInterface
{
return $this->response->getBody();
}
public function withBody(StreamInterface $body): ServerResponse|ResponseInterface
{
return $this->response->withBody($body);
}
public function getStatusCode(): int
{
return $this->response->getStatusCode();
}
public function withStatus($code, $reasonPhrase = ''): ServerResponse|ResponseInterface
{
$this->response->withStatus($code, $reasonPhrase);
return $this;
}
public function getReasonPhrase(): string
{
return $this->response->getReasonPhrase();
}
/**
* @link https://github.com/symfony/http-foundation/blob/6.1/Response.php
*/
public function send(): static
{
$this->sendHeaders();
$this->sendContent();
if (\function_exists('fastcgi_finish_request')) {
\fastcgi_finish_request();
} elseif (\function_exists('litespeed_finish_request')) {
\litespeed_finish_request();
} elseif (! \in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) {
static::closeOutputBuffers(0, true);
}
return $this;
}
public function sendHeaders(): static
{
// headers have already been sent by the developer
if (\headers_sent()) {
return $this;
}
foreach ($this->getHeaders() as $name => $values) {
$replace = 0 === \strcasecmp($name, 'Content-Type');
foreach ($values as $value) {
header($name.': '.$value, $replace, $this->getStatusCode());
}
}
header(
header: sprintf(
'HTTP/%s %s %s',
$this->getProtocolVersion(),
$this->getStatusCode(),
$this->getReasonPhrase()
),
replace: true,
response_code: $this->getStatusCode()
);
return $this;
}
public function sendContent(): static
{
echo (string) $this->getBody();
return $this;
}
/**
* Cleans or flushes output buffers up to target level.
*
* Resulting level can be greater than target level if a non-removable buffer has been encountered.
*
* @link https://github.com/symfony/http-foundation/blob/6.1/Response.php
* @final
*/
public static function closeOutputBuffers(int $targetLevel, bool $flush): void
{
$status = ob_get_status(true);
$level = count($status);
$flags = PHP_OUTPUT_HANDLER_REMOVABLE | ($flush ? PHP_OUTPUT_HANDLER_FLUSHABLE : PHP_OUTPUT_HANDLER_CLEANABLE);
while ($level-- > $targetLevel && ($s = $status[$level]) && (! isset($s['del']) ? ! isset($s['flags']) || ($s['flags'] & $flags) === $flags : $s['del'])) {
if ($flush) {
ob_end_flush();
} else {
ob_end_clean();
}
}
}
public function __toString(): string
{
$headers = $this->getHeaders();
ksort($headers);
$max = max(array_map('strlen', array_keys($headers))) + 1;
$headersString = '';
foreach ($headers as $name => $values) {
$name = ucwords($name, '-');
foreach ($values as $value) {
$headersString .= sprintf("%-{$max}s %s\r\n", $name.':', $value);
}
}
return sprintf(
'HTTP/%s %s %s',
$this->getProtocolVersion(),
$this->getStatusCode(),
$this->getReasonPhrase()
)."\r\n".
$headersString."\r\n".
$this->getBody();
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace EasyWeChat\Kernel\Support;
use function base64_decode;
use EasyWeChat\Kernel\Contracts\Aes;
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
use function openssl_decrypt;
use function openssl_error_string;
use const OPENSSL_RAW_DATA;
class AesCbc implements Aes
{
/**
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
*/
public static function encrypt(string $plaintext, string $key, string $iv = null): string
{
$ciphertext = \openssl_encrypt($plaintext, 'aes-128-cbc', $key, OPENSSL_RAW_DATA, (string) $iv);
if (false === $ciphertext) {
throw new InvalidArgumentException(openssl_error_string() ?: 'Encrypt AES CBC error.');
}
return base64_encode($ciphertext);
}
/**
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
*/
public static function decrypt(string $ciphertext, string $key, string $iv = null): string
{
$plaintext = openssl_decrypt(
base64_decode($ciphertext),
'aes-128-cbc',
$key,
OPENSSL_RAW_DATA,
(string) $iv
);
if (false === $plaintext) {
throw new InvalidArgumentException(openssl_error_string() ?: 'Decrypt AES CBC error.');
}
return $plaintext;
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace EasyWeChat\Kernel\Support;
use function base64_decode;
use EasyWeChat\Kernel\Contracts\Aes;
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
use function openssl_decrypt;
use function openssl_error_string;
use const OPENSSL_RAW_DATA;
class AesEcb implements Aes
{
/**
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
*/
public static function encrypt(string $plaintext, string $key, string $iv = null): string
{
$ciphertext = \openssl_encrypt($plaintext, 'aes-256-ecb', $key, OPENSSL_RAW_DATA, (string) $iv);
if (false === $ciphertext) {
throw new InvalidArgumentException(openssl_error_string() ?: 'Encrypt AES ECB failed.');
}
return \base64_encode($ciphertext);
}
/**
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
*/
public static function decrypt(string $ciphertext, string $key, string $iv = null): string
{
$plaintext = openssl_decrypt(
base64_decode($ciphertext, true) ?: '',
'aes-256-ecb',
$key,
OPENSSL_RAW_DATA,
(string) $iv
);
if (false === $plaintext) {
throw new InvalidArgumentException(openssl_error_string() ?: 'Decrypt AES ECB failed.');
}
return $plaintext;
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace EasyWeChat\Kernel\Support;
use function base64_decode;
use function base64_encode;
use EasyWeChat\Kernel\Contracts\Aes;
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
use function openssl_decrypt;
use function openssl_encrypt;
use function openssl_error_string;
use const OPENSSL_RAW_DATA;
class AesGcm implements Aes
{
public const BLOCK_SIZE = 16;
/**
* @throws InvalidArgumentException
*/
public static function encrypt(string $plaintext, string $key, string $iv = null, string $aad = ''): string
{
$ciphertext = openssl_encrypt(
$plaintext,
'aes-256-gcm',
$key,
OPENSSL_RAW_DATA,
(string) $iv,
$tag,
$aad,
self::BLOCK_SIZE
);
if (false === $ciphertext) {
throw new InvalidArgumentException(openssl_error_string() ?: 'Encrypt failed');
}
return base64_encode($ciphertext.$tag);
}
/**
* @throws InvalidArgumentException
*/
public static function decrypt(string $ciphertext, string $key, string $iv = null, string $aad = ''): string
{
$ciphertext = base64_decode($ciphertext);
$tag = substr($ciphertext, -self::BLOCK_SIZE);
$ciphertext = substr($ciphertext, 0, -self::BLOCK_SIZE);
$plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, (string) $iv, $tag, $aad);
if (false === $plaintext) {
throw new InvalidArgumentException(openssl_error_string() ?: 'Decrypt failed');
}
return $plaintext;
}
}

View File

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Support;
use function is_string;
use JetBrains\PhpStorm\Pure;
class Arr
{
/**
* @param array<string|int, mixed> $array
* @param string|int|null $key
* @param mixed $default
* @return mixed
*/
#[Pure]
public static function get(array $array, string|int|null $key, mixed $default = null): mixed
{
if (is_null($key)) {
return $array;
}
if (static::exists($array, $key)) {
return $array[$key];
}
foreach (explode('.', (string) $key) as $segment) {
/** @phpstan-ignore-next-line */
if (static::exists($array, $segment)) {
/** @phpstan-ignore-next-line */
$array = $array[$segment];
} else {
return $default;
}
}
return $array;
}
/**
* @param array<int|string, mixed> $array
* @param string|int $key
* @return bool
*/
public static function exists(array $array, string|int $key): bool
{
return array_key_exists($key, $array);
}
/**
* @param array<string|int, mixed> $array
* @param string|int|null $key
* @param mixed $value
* @return array<string|int, mixed>
*/
public static function set(array &$array, string|int|null $key, mixed $value): array
{
if (! is_string($key)) {
$key = (string) $key;
}
$keys = explode('.', $key);
while (count($keys) > 1) {
$key = array_shift($keys);
// If the key doesn't exist at this depth, we will just create an empty array
// to hold the next value, allowing us to create the arrays to hold final
// values at the correct depth. Then we'll keep digging into the array.
if (! isset($array[$key]) || ! is_array($array[$key])) {
$array[$key] = [];
}
$array = &$array[$key];
}
$array[array_shift($keys)] = $value;
return $array;
}
/**
* @param array<string|int, mixed> $array
* @param string $prepend
* @return array<string|int, mixed>
*/
public static function dot(array $array, string $prepend = ''): array
{
$results = [];
foreach ($array as $key => $value) {
if (is_array($value) && ! empty($value)) {
$results = array_merge($results, static::dot($value, $prepend.$key.'.'));
} else {
$results[$prepend.$key] = $value;
}
}
return $results;
}
/**
* @param array<string|int, mixed> $array
* @param string|int|array<string|int, mixed>|null $keys
* @return bool
*/
#[Pure]
public static function has(array $array, string|int|array|null $keys): bool
{
if (is_null($keys)) {
return false;
}
$keys = (array) $keys;
if (empty($array)) {
return false;
}
if ([] === $keys) {
return false;
}
foreach ($keys as $key) {
$subKeyArray = $array;
/** @phpstan-ignore-next-line */
if (static::exists($array, $key)) {
continue;
}
/** @phpstan-ignore-next-line */
foreach (explode('.', (string) $key) as $segment) {
/** @phpstan-ignore-next-line */
if (static::exists($subKeyArray, $segment)) {
/** @phpstan-ignore-next-line */
$subKeyArray = $subKeyArray[$segment];
} else {
return false;
}
}
}
return true;
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace EasyWeChat\Kernel\Support;
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
class Pkcs7
{
/**
* @throws InvalidArgumentException
*/
public static function padding(string $contents, int $blockSize): string
{
if ($blockSize > 256) {
throw new InvalidArgumentException('$blockSize may not be more than 256');
}
$padding = $blockSize - (strlen($contents) % $blockSize);
$pattern = chr($padding);
return $contents.str_repeat($pattern, $padding);
}
public static function unpadding(string $contents, int $blockSize): string
{
$pad = ord(substr($contents, -1));
if ($pad < 1 || $pad > $blockSize) {
$pad = 0;
}
return substr($contents, 0, (strlen($contents) - $pad));
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace EasyWeChat\Kernel\Support;
use function file_exists;
use function file_get_contents;
use JetBrains\PhpStorm\Pure;
use function str_starts_with;
class PrivateKey
{
public function __construct(protected string $key, protected ?string $passphrase = null)
{
if (file_exists($key)) {
$this->key = "file://{$key}";
}
}
public function getKey(): string
{
if (str_starts_with($this->key, 'file://')) {
return file_get_contents($this->key) ?: '';
}
return $this->key;
}
public function getPassphrase(): ?string
{
return $this->passphrase;
}
#[Pure]
public function __toString(): string
{
return $this->getKey();
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace EasyWeChat\Kernel\Support;
use EasyWeChat\Kernel\Exceptions\InvalidConfigException;
use function file_exists;
use function file_get_contents;
use function openssl_x509_parse;
use function str_starts_with;
use function strtoupper;
class PublicKey
{
public function __construct(public string $certificate)
{
if (file_exists($certificate)) {
$this->certificate = "file://{$certificate}";
}
}
/**
* @throws InvalidConfigException
*/
public function getSerialNo(): string
{
$info = openssl_x509_parse($this->certificate);
if (false === $info || ! isset($info['serialNumberHex'])) {
throw new InvalidConfigException('Read the $certificate failed, please check it whether or nor correct');
}
return strtoupper($info['serialNumberHex'] ?? '');
}
public function __toString(): string
{
if (str_starts_with($this->certificate, 'file://')) {
return file_get_contents($this->certificate) ?: '';
}
return $this->certificate;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace EasyWeChat\Kernel\Support;
use function base64_encode;
use Exception;
use function preg_replace;
use function random_bytes;
use function str_replace;
use function strlen;
use function strtolower;
use function substr;
use function trim;
class Str
{
/**
* From https://github.com/laravel/framework/blob/9.x/src/Illuminate/Support/Str.php#L632-L644
*
* @throws Exception
*/
public static function random(int $length = 16): string
{
$string = '';
while (($len = strlen($string)) < $length) {
$size = $length - $len;
/** @phpstan-ignore-next-line */
$bytes = random_bytes($size);
$string .= substr(str_replace(['/', '+', '='], '', base64_encode($bytes)), 0, $size);
}
return $string;
}
public static function snakeCase(string $string): string
{
return trim(strtolower((string) preg_replace('/[A-Z]([A-Z](?![a-z]))*/', '_$0', $string)), '_');
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Support;
use function array_map;
use function array_unshift;
use function class_exists;
use Composer\InstalledVersions;
use function curl_version;
use function defined;
use function explode;
use function extension_loaded;
use function function_exists;
use function ini_get;
class UserAgent
{
/**
* @param array<string> $appends
* @return string
*/
public static function create(array $appends = []): string
{
$value = array_map('strval', $appends);
if (defined('HHVM_VERSION')) {
array_unshift($value, 'HHVM/'.HHVM_VERSION);
}
$disabledFunctions = explode(',', ini_get('disable_functions') ?: '');
if (extension_loaded('curl') && function_exists('curl_version')) {
array_unshift($value, 'curl/'.(curl_version() ?: ['version' => 'unknown'])['version']);
}
if (! ini_get('safe_mode')
&& function_exists('php_uname')
&& ! in_array('php_uname', $disabledFunctions, true)
) {
$osName = 'OS/'.php_uname('s').'/'.php_uname('r');
array_unshift($value, $osName);
}
if (class_exists(InstalledVersions::class)) {
array_unshift($value, 'easywechat-sdk/'.((string) InstalledVersions::getVersion('w7corp/easywechat')));
}
return trim(implode(' ', $value));
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Support;
use TheNorthMemory\Xml\Transformer;
class Xml
{
public static function parse(string $xml): array|null
{
return Transformer::toArray($xml);
}
public static function build(array $data): string
{
return Transformer::toXml($data);
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace EasyWeChat\Kernel\Traits;
use EasyWeChat\Kernel\Encryptor;
use EasyWeChat\Kernel\Exceptions\BadRequestException;
use EasyWeChat\Kernel\Message;
use EasyWeChat\Kernel\Support\Xml;
trait DecryptXmlMessage
{
/**
* @throws \EasyWeChat\Kernel\Exceptions\RuntimeException
* @throws BadRequestException
*/
public function decryptMessage(
Message $message,
Encryptor $encryptor,
string $signature,
int|string $timestamp,
string $nonce
): Message {
$ciphertext = $message->Encrypt;
$this->validateSignature($encryptor->getToken(), $ciphertext, $signature, $timestamp, $nonce);
$message->merge(Xml::parse(
$encryptor->decrypt(
ciphertext: $ciphertext,
msgSignature: $signature,
nonce: $nonce,
timestamp: $timestamp
)
) ?? []);
return $message;
}
/**
* @throws BadRequestException
*/
protected function validateSignature(
string $token,
string $ciphertext,
string $signature,
int|string $timestamp,
string $nonce
): void {
if (empty($signature)) {
throw new BadRequestException('Request signature must not be empty.');
}
$params = [$token, $timestamp, $nonce, $ciphertext];
sort($params, SORT_STRING);
if ($signature !== sha1(implode($params))) {
throw new BadRequestException('Invalid request signature.');
}
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Traits;
use function array_key_exists;
use function array_merge;
use function json_encode;
trait HasAttributes
{
/**
* @var array<int|string,mixed>
*/
protected array $attributes = [];
/**
* @param array<int|string,mixed> $attributes
*/
public function __construct(array $attributes)
{
$this->attributes = $attributes;
}
/**
* @return array<int|string,mixed>
*/
public function toArray(): array
{
return $this->attributes;
}
public function toJson(): string|false
{
return json_encode($this->attributes);
}
public function has(string $key): bool
{
return array_key_exists($key, $this->attributes);
}
/**
* @param array<int|string,mixed> $attributes
*/
public function merge(array $attributes): static
{
$this->attributes = array_merge($this->attributes, $attributes);
return $this;
}
/**
* @return array<int|string,mixed> $attributes
*/
public function jsonSerialize(): array
{
return $this->attributes;
}
public function __set(string $attribute, mixed $value): void
{
$this->attributes[$attribute] = $value;
}
public function __get(string $attribute): mixed
{
return $this->attributes[$attribute] ?? null;
}
public function offsetExists(mixed $offset): bool
{
/** @phpstan-ignore-next-line */
return array_key_exists($offset, $this->attributes);
}
public function offsetGet(mixed $offset): mixed
{
return $this->attributes[$offset];
}
public function offsetSet(mixed $offset, mixed $value): void
{
if (null === $offset) {
$this->attributes[] = $value;
} else {
$this->attributes[$offset] = $value;
}
}
public function offsetUnset(mixed $offset): void
{
unset($this->attributes[$offset]);
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Traits;
use Psr\SimpleCache\CacheInterface;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Psr16Cache;
trait InteractWithCache
{
protected ?CacheInterface $cache = null;
protected int $cacheLifetime = 1500;
protected string $cacheNamespace = 'easywechat';
public function getCacheLifetime(): int
{
return $this->cacheLifetime;
}
public function setCacheLifetime(int $cacheLifetime): void
{
$this->cacheLifetime = $cacheLifetime;
}
public function getCacheNamespace(): string
{
return $this->cacheNamespace;
}
public function setCacheNamespace(string $cacheNamespace): void
{
$this->cacheNamespace = $cacheNamespace;
}
public function setCache(CacheInterface $cache): static
{
$this->cache = $cache;
return $this;
}
public function getCache(): CacheInterface
{
if (! $this->cache) {
$this->cache = new Psr16Cache(new FilesystemAdapter($this->cacheNamespace, $this->cacheLifetime));
}
return $this->cache;
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Traits;
use EasyWeChat\Kernel\HttpClient\AccessTokenAwareClient;
trait InteractWithClient
{
protected ?AccessTokenAwareClient $client = null;
public function getClient(): AccessTokenAwareClient
{
if (! $this->client) {
$this->client = $this->createClient();
}
return $this->client;
}
public function setClient(AccessTokenAwareClient $client): static
{
$this->client = $client;
return $this;
}
abstract public function createClient(): AccessTokenAwareClient;
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Traits;
use EasyWeChat\Kernel\Config;
use EasyWeChat\Kernel\Contracts\Config as ConfigInterface;
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
use function is_array;
trait InteractWithConfig
{
protected ConfigInterface $config;
/**
* @param array<string,mixed>|ConfigInterface $config
*
* @throws InvalidArgumentException
*/
public function __construct(array|ConfigInterface $config)
{
$this->config = is_array($config) ? new Config($config) : $config;
}
public function getConfig(): ConfigInterface
{
return $this->config;
}
public function setConfig(ConfigInterface $config): static
{
$this->config = $config;
return $this;
}
}

View File

@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Traits;
use function array_reverse;
use function array_unshift;
use function call_user_func;
use Closure;
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
use function func_get_args;
use function gettype;
use function is_array;
use function is_callable;
use function is_string;
use JetBrains\PhpStorm\ArrayShape;
use function method_exists;
use function spl_object_hash;
trait InteractWithHandlers
{
/**
* @var array<int, array{hash: string, handler: callable}>
*/
protected array $handlers = [];
/**
* @return array<int, array{hash: string, handler: callable}>
*/
public function getHandlers(): array
{
return $this->handlers;
}
/**
* @throws InvalidArgumentException
*/
public function with(callable|string $handler): static
{
return $this->withHandler($handler);
}
/**
* @throws InvalidArgumentException
*/
public function withHandler(callable|string $handler): static
{
$this->handlers[] = $this->createHandlerItem($handler);
return $this;
}
/**
* @param callable|string $handler
* @return array{hash: string, handler: callable}
*
* @throws InvalidArgumentException
*/
#[ArrayShape(['hash' => 'string', 'handler' => 'callable'])]
public function createHandlerItem(callable|string $handler): array
{
return [
'hash' => $this->getHandlerHash($handler),
'handler' => $this->makeClosure($handler),
];
}
/**
* @throws InvalidArgumentException
*/
protected function getHandlerHash(callable|string $handler): string
{
return match (true) {
is_string($handler) => $handler,
is_array($handler) => is_string($handler[0]) ? $handler[0].'::'.$handler[1] : get_class(
$handler[0]
).$handler[1],
$handler instanceof Closure => spl_object_hash($handler),
default => throw new InvalidArgumentException('Invalid handler: '.gettype($handler)),
};
}
/**
* @throws InvalidArgumentException
*/
protected function makeClosure(callable|string $handler): callable
{
if (is_callable($handler)) {
return $handler;
}
if (class_exists($handler) && method_exists($handler, '__invoke')) {
/**
* @psalm-suppress InvalidFunctionCall
* @phpstan-ignore-next-line https://github.com/phpstan/phpstan/issues/5867
*/
return fn (): mixed => (new $handler())(...func_get_args());
}
throw new InvalidArgumentException(sprintf('Invalid handler: %s.', $handler));
}
/**
* @throws InvalidArgumentException
*/
public function prepend(callable|string $handler): static
{
return $this->prependHandler($handler);
}
/**
* @throws InvalidArgumentException
*/
public function prependHandler(callable|string $handler): static
{
array_unshift($this->handlers, $this->createHandlerItem($handler));
return $this;
}
/**
* @throws InvalidArgumentException
*/
public function without(callable|string $handler): static
{
return $this->withoutHandler($handler);
}
/**
* @throws InvalidArgumentException
*/
public function withoutHandler(callable|string $handler): static
{
$index = $this->indexOf($handler);
if ($index > -1) {
unset($this->handlers[$index]);
}
return $this;
}
/**
* @throws InvalidArgumentException
*/
public function indexOf(callable|string $handler): int
{
foreach ($this->handlers as $index => $item) {
if ($item['hash'] === $this->getHandlerHash($handler)) {
return $index;
}
}
return -1;
}
/**
* @throws InvalidArgumentException
*/
public function when(mixed $value, callable|string $handler): static
{
if (is_callable($value)) {
$value = call_user_func($value, $this);
}
if ($value) {
return $this->withHandler($handler);
}
return $this;
}
public function handle(mixed $result, mixed $payload = null): mixed
{
$next = $result = is_callable($result) ? $result : fn (mixed $p): mixed => $result;
foreach (array_reverse($this->handlers) as $item) {
$next = fn (mixed $p): mixed => $item['handler']($p, $next) ?? $result($p);
}
return $next($payload);
}
/**
* @throws InvalidArgumentException
*/
public function has(callable|string $handler): bool
{
return $this->indexOf($handler) > -1;
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Traits;
use EasyWeChat\Kernel\HttpClient\RequestUtil;
use function property_exists;
use Psr\Log\LoggerAwareInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
trait InteractWithHttpClient
{
protected ?HttpClientInterface $httpClient = null;
public function getHttpClient(): HttpClientInterface
{
if (! $this->httpClient) {
$this->httpClient = $this->createHttpClient();
}
return $this->httpClient;
}
public function setHttpClient(HttpClientInterface $httpClient): static
{
$this->httpClient = $httpClient;
if ($this instanceof LoggerAwareInterface && $httpClient instanceof LoggerAwareInterface
&& property_exists($this, 'logger')
&& $this->logger) {
$httpClient->setLogger($this->logger);
}
return $this;
}
protected function createHttpClient(): HttpClientInterface
{
return HttpClient::create(RequestUtil::formatDefaultOptions($this->getHttpClientDefaultOptions()));
}
/**
* @return array<string,mixed>
*/
protected function getHttpClientDefaultOptions(): array
{
return [];
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace EasyWeChat\Kernel\Traits;
use EasyWeChat\Kernel\HttpClient\RequestUtil;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Symfony\Component\HttpFoundation\Request;
trait InteractWithServerRequest
{
protected ?ServerRequestInterface $request = null;
public function getRequest(): ServerRequestInterface
{
if (! $this->request) {
$this->request = RequestUtil::createDefaultServerRequest();
}
return $this->request;
}
public function setRequest(ServerRequestInterface $request): static
{
$this->request = $request;
return $this;
}
public function setRequestFromSymfonyRequest(Request $symfonyRequest): static
{
$psr17Factory = new Psr17Factory();
$psrHttpFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
$this->request = $psrHttpFactory->createRequest($symfonyRequest);
return $this;
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace EasyWeChat\Kernel\Traits;
use JetBrains\PhpStorm\Pure;
use Mockery\Mock;
use Symfony\Component\HttpClient\DecoratorTrait;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\HttpClientInterface;
trait MockableHttpClient
{
public static function createMockClient(MockHttpClient $mockHttpClient): HttpClientInterface
{
return new self($mockHttpClient);
}
/**
* @param array<string,mixed> $headers
*/
public static function mock(
string $response = '',
?int $status = 200,
array $headers = [],
string $baseUri = 'https://example.com'
): object {
$mockResponse = new MockResponse(
$response,
array_merge([
'http_code' => $status,
'content_type' => 'application/json',
], $headers)
);
$client = self::createMockClient(new MockHttpClient($mockResponse, $baseUri));
// @phpstan-ignore-next-line
return new class($client, $mockResponse)
{
use DecoratorTrait;
public function __construct(Mock|HttpClientInterface $client, public MockResponse $mockResponse)
{
$this->client = $client;
}
/**
* @param array<string,mixed> $arguments
*/
public function __call(string $name, array $arguments): mixed
{
return $this->client->$name(...$arguments);
}
#[Pure]
public function getRequestMethod(): string
{
return $this->mockResponse->getRequestMethod();
}
#[Pure]
public function getRequestUrl(): string
{
return $this->mockResponse->getRequestUrl();
}
/**
* @return array<string, mixed>
*/
#[Pure]
public function getRequestOptions(): array
{
return $this->mockResponse->getRequestOptions();
}
};
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace EasyWeChat\Kernel\Traits;
use function array_merge;
use EasyWeChat\Kernel\Encryptor;
use EasyWeChat\Kernel\Exceptions\InvalidArgumentException;
use EasyWeChat\Kernel\Exceptions\RuntimeException;
use EasyWeChat\Kernel\Message;
use EasyWeChat\Kernel\Support\Xml;
use function is_array;
use function is_callable;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use function time;
trait RespondXmlMessage
{
/**
* @throws RuntimeException
* @throws InvalidArgumentException
*/
public function transformToReply(mixed $response, Message $message, ?Encryptor $encryptor = null): ResponseInterface
{
if (empty($response)) {
return new Response(200, [], 'success');
}
return $this->createXmlResponse(
attributes: array_filter(
array_merge(
[
'ToUserName' => $message->FromUserName,
'FromUserName' => $message->ToUserName,
'CreateTime' => time(),
],
$this->normalizeResponse($response),
)
),
encryptor: $encryptor
);
}
/**
* @return array<string, mixed>
*
* @throws InvalidArgumentException
*/
protected function normalizeResponse(mixed $response): array
{
if (is_callable($response)) {
$response = $response();
}
if (is_array($response)) {
if (! isset($response['MsgType'])) {
throw new InvalidArgumentException('MsgType cannot be empty.');
}
return $response;
}
if (is_string($response) || is_numeric($response)) {
return [
'MsgType' => 'text',
'Content' => $response,
];
}
throw new InvalidArgumentException(
sprintf('Invalid Response type "%s".', gettype($response))
);
}
/**
* @param array<string, mixed> $attributes
*
* @throws RuntimeException
*/
protected function createXmlResponse(array $attributes, ?Encryptor $encryptor = null): ResponseInterface
{
$xml = Xml::build($attributes);
return new Response(200, ['Content-Type' => 'application/xml'], $encryptor ? $encryptor->encrypt($xml) : $xml);
}
}