Initial commit!

This commit is contained in:
flash 2024-11-16 04:12:29 +00:00
commit 8712013261
39 changed files with 4105 additions and 0 deletions

4
.gitattributes vendored Normal file
View file

@ -0,0 +1,4 @@
* text=auto
*.sh text eol=lf
*.php text eol=lf
VERSION text eol=lf

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
[Tt]humbs.db
[Dd]esktop.ini
.DS_Store
.vscode/
.vs/
.idea/
html/
.phpdoc*
.phpunit*
vendor/

30
LICENCE Normal file
View file

@ -0,0 +1,30 @@
Copyright (c) 2024, flashwave <me@flash.moe>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted (subject to the limitations in the disclaimer
below) provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

31
README.md Normal file
View file

@ -0,0 +1,31 @@
# Flashii PHP API client
This is the source for the first party Flashii API client implementation in PHP.
## Requirements and Dependencies
This library currently targets **PHP 8.1**.
## Versioning
Versioning will follows the [Semantic Versioning specification v2.0.0](https://semver.org/spec/v2.0.0.html).
The MAJOR version component will always be in sync with the implemented version of the Flashii API.
The MINOR version component will be incremented depending changes to the library itself, be it feature additions or removals or changes to minimum required PHP version.
The version is stored in the root of the repository in a file called `VERSION` and can be read using `Flashii\FlashiiClient::version()`.
## Contribution
By submitting code for inclusion in the main source tree you agree to transfer ownership of the code to the project owner.
The contributor will still be attributed for the contributed code, unless they ask for this attribution to be removed.
This is to avoid intellectual property rights traps and drama that could lead to blackmail situations.
If you do not agree with these terms, you are free to fork off.
## Licencing
The Flashii API client is available under the BSD 3-Clause Clear License, a full version of which is enclosed in the LICENCE file.

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.1.0

27
composer.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "flashii/apii",
"description": "Client library for the Flashii.net API.",
"type": "library",
"homepage": "https://api.flashii.net",
"license": "bsd-3-clause-clear",
"require": {
"php": ">=8.1"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^1.12"
},
"authors": [
{
"name": "flashwave",
"email": "packagist@flash.moe",
"homepage": "https://flash.moe",
"role": "mom"
}
],
"autoload": {
"psr-4": {
"Flashii\\": "src"
}
}
}

1703
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

35
phpdoc.xml Normal file
View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" ?>
<phpdocumentor configVersion="3.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://www.phpdoc.org"
xsi:noNamespaceSchemaLocation="data/xsd/phpdoc.xsd">
<title>Flashii PHP API Client Documentation</title>
<template name="default"/>
<paths>
<output>html</output>
</paths>
<version number="1.0">
<folder>latest</folder>
<api format="php">
<output>api</output>
<visibility>public</visibility>
<default-package-name>Flashii</default-package-name>
<source dsn=".">
<path>src</path>
</source>
<extensions>
<extension>php</extension>
</extensions>
<ignore hidden="true" symlinks="true">
<path>tests/**/*</path>
</ignore>
<ignore-tags>
<ignore-tag>template</ignore-tag>
<ignore-tag>template-extends</ignore-tag>
<ignore-tag>template-implements</ignore-tag>
<ignore-tag>extends</ignore-tag>
<ignore-tag>implements</ignore-tag>
</ignore-tags>
</api>
</version>
</phpdocumentor>

8
phpstan.neon Normal file
View file

@ -0,0 +1,8 @@
parameters:
level: 9
checkUninitializedProperties: true
checkImplicitMixed: true
checkBenevolentUnionTypes: true
paths:
- src
- tests

23
phpunit.xml Normal file
View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
colors="true"
executionOrder="depends,defects"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true"
cacheDirectory=".phpunit.cache"
requireCoverageMetadata="true"
beStrictAboutCoverageMetadata="true">
<testsuites>
<testsuite name="default">
<directory suffix="Test.php">tests</directory>
</testsuite>
</testsuites>
<coverage/>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>

View file

@ -0,0 +1,63 @@
<?php declare(strict_types=1);
// AuthorizedHttpClient.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii;
use Flashii\Credentials\Credentials;
use Flashii\Http\{HttpClient,HttpRequest,HttpResponse};
/**
* Provides a proxy HttpClient that ensures the HTTP Authorization header is set on requests.
*/
class AuthorizedHttpClient implements HttpClient {
/**
* @param HttpClient $httpClient Underlying HttpClient implementation.
* @param Credentials $credentials Credentials to use for the Authorization header.
*/
public function __construct(
private HttpClient $httpClient,
private Credentials $credentials
) {}
/**
* Retrieves the underlying HttpClient instance.
*
* @return HttpClient
*/
public function getHttpClient(): HttpClient {
return $this->httpClient;
}
/**
* Retrieves the authorization credentials.
*
* @return Credentials
*/
public function getCredentials(): Credentials {
return $this->credentials;
}
public function createRequest(string $method, string $url): HttpRequest {
return $this->httpClient->createRequest($method, $url);
}
public function sendRequest(HttpRequest $request): HttpResponse {
$authz = $this->credentials->getHttpAuthorization();
if($authz !== '')
$request->setHeader('Authorization', $authz);
return $this->httpClient->sendRequest($request);
}
/**
* Sends a HTTP request and returns a HTTP response without applying the HTTP authorization header.
*
* @param HttpRequest $request Request to send.
* @return HttpResponse
*/
public function sendRequestAnonymous(HttpRequest $request): HttpResponse {
return $this->httpClient->sendRequest($request);
}
}

View file

@ -0,0 +1,15 @@
<?php declare(strict_types=1);
// AnonymousCredentials.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Credentials;
/**
* Implements anonymous credentials.
*/
class AnonymousCredentials implements Credentials {
public function getHttpAuthorization(): string {
return '';
}
}

View file

@ -0,0 +1,41 @@
<?php declare(strict_types=1);
// BasicCredentials.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Credentials;
/**
* Implements basic HTTP credentials for OAuth2 apps.
*/
class BasicCredentials implements Credentials {
private string $authz;
/**
* @param string $userName User name/Client ID.
* @param string $password Password/Client secret.
*/
public function __construct(
private string $userName,
#[\SensitiveParameter]
string $password = ''
) {
$authz = $userName;
if($password !== '')
$authz .= sprintf(':%s', $password);
$this->authz = sprintf('Basic %s', base64_encode($authz));
}
public function getUserName(): string {
return $this->userName;
}
public function getHttpAuthorization(): string {
return $this->authz;
}
public function __debugInfo(): array {
return ['authz' => 'Basic #####'];
}
}

View file

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
// BearerCredentials.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Credentials;
/**
* Implements bearer HTTP credentials for OAuth2 access tokens.
*/
class BearerCredentials implements Credentials {
private string $authz;
/**
* @param string $accessToken Bearer access token.
*/
public function __construct(
#[\SensitiveParameter]
string $accessToken
) {
$this->authz = sprintf('Bearer %s', $accessToken);
}
public function getHttpAuthorization(): string {
return $this->authz;
}
public function __debugInfo(): array {
return ['authz' => 'Bearer #####'];
}
}

View file

@ -0,0 +1,20 @@
<?php declare(strict_types=1);
// Credentials.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Credentials;
/**
* Provides an interface for Flashii API credentials.
*/
interface Credentials {
/**
* Returns what to set the HTTP Authorization header to.
*
* If the output is blank the header is not supplied.
*
* @return string
*/
function getHttpAuthorization(): string;
}

View file

@ -0,0 +1,33 @@
<?php declare(strict_types=1);
// MisuzuCredentials.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Credentials;
/**
* Implements Misuzu HTTP credentials for internal session token auth.
*
* Do not use this unless you're flashwave!!!!!!! bad!!!!!!!!
*/
class MisuzuCredentials implements Credentials {
private string $authz;
/**
* @param string $sessionToken Misuzu session token.
*/
public function __construct(
#[\SensitiveParameter]
string $sessionToken
) {
$this->authz = sprintf('Misuzu %s', $sessionToken);
}
public function getHttpAuthorization(): string {
return $this->authz;
}
public function __debugInfo(): array {
return ['authz' => 'Misuzu #####'];
}
}

235
src/FlashiiClient.php Normal file
View file

@ -0,0 +1,235 @@
<?php declare(strict_types=1);
// FlashiiClient.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii;
use RuntimeException;
use Flashii\Credentials\{AnonymousCredentials,BasicCredentials,BearerCredentials,Credentials,MisuzuCredentials};
use Flashii\Http\HttpClient;
use Flashii\Http\Curl\CurlHttpClient;
use Flashii\Http\Stream\StreamHttpClient;
use Flashii\OAuth2\OAuth2Client;
/**
* Flashii API client.
*/
class FlashiiClient {
/**
* Library source directory path.
*
* @var string
*/
public const LIB_PATH_SOURCE = __DIR__;
/**
* Library root directory path.
*
* @var string
*/
public const LIB_PATH_ROOT = self::LIB_PATH_SOURCE . DIRECTORY_SEPARATOR . '..';
/**
* Library version file path.
*
* @var string
*/
public const LIB_PATH_VERSION = self::LIB_PATH_ROOT . DIRECTORY_SEPARATOR . 'VERSION';
private AuthorizedHttpClient $httpClient;
private Credentials $credentials;
private FlashiiUrls $urls;
// Use newLazyGhost to initialise these once PHP 8.4 can be targeted proper
private ?OAuth2Client $oauth2 = null;
/**
* @param string $userAgentOrHttpClient User agent string for your application or a HttpClient implementation, providing a string will autodetect whether to use cURL or PHP streams.
* @param ?Credentials $credentials Credentials to authenticate with, leave null for unauthorised.
* @param ?FlashiiUrls $urls Object containing base URLs, leave null to use the production environment.
*/
public function __construct(
string|HttpClient $userAgentOrHttpClient = '',
?Credentials $credentials = null,
?FlashiiUrls $urls = null
) {
$this->urls = $urls ?? FlashiiUrls::production();
$this->credentials = $credentials ?? new AnonymousCredentials;
if(is_string($userAgentOrHttpClient))
$httpClient = self::createHttpClient($userAgentOrHttpClient);
else {
if($userAgentOrHttpClient instanceof AuthorizedHttpClient)
$httpClient = $userAgentOrHttpClient->getHttpClient();
else
$httpClient = $userAgentOrHttpClient;
}
$this->httpClient = new AuthorizedHttpClient(
$httpClient,
$this->credentials
);
}
/**
* Retrieves the OAuth2 API client portion.
*
* @return OAuth2Client
*/
public function oauth2(): OAuth2Client {
$this->oauth2 ??= new OAuth2Client(
$this,
$this->httpClient,
$this->credentials,
$this->urls
);
return $this->oauth2;
}
/**
* Fork this API client with different credentials.
*
* @param Credentials $credentials Credentials to create a new client with.
* @return FlashiiClient
*/
public function fork(Credentials $credentials): self {
return new FlashiiClient(
$this->httpClient,
$credentials,
$this->urls
);
}
/**
* Forks the current Flashii API client with a bearer access token.
*
* Given that refresh tokens have to be used with app authorization, it probably makes sense to have this around.
*
* @param string $accessToken Bearer access token.
* @return FlashiiClient
*/
public function forkWithAccessToken(
#[\SensitiveParameter]
string $accessToken
): self {
return $this->fork(new BearerCredentials($accessToken));
}
/**
* Creates an anonymous Flashii API client.
*
* @param string $userAgent Application user agent.
* @return FlashiiClient
*/
public static function anonymous(string $userAgent = ''): self {
return new FlashiiClient($userAgent);
}
/**
* Creates an app authenticated Flashii API client.
*
* @param string $clientId OAuth2 application client id.
* @param string $clientSecret OAuth2 application client secret.
* @param string $userAgent Application user agent.
* @return FlashiiClient
*/
public static function withClientId(
string $clientId,
#[\SensitiveParameter]
string $clientSecret = '',
string $userAgent = ''
): self {
return new FlashiiClient(
$userAgent,
new BasicCredentials($clientId, $clientSecret)
);
}
/**
* Creates a bearer authenticated Flashii API client.
*
* @param string $accessToken Bearer access token.
* @param string $userAgent Application user agent.
* @return FlashiiClient
*/
public static function withAccessToken(
#[\SensitiveParameter]
string $accessToken,
string $userAgent = ''
): self {
return new FlashiiClient(
$userAgent,
new BearerCredentials($accessToken)
);
}
/**
* Creates a Misuzu authenticated Flashii API client.
*
* Do not use this unless you're flashwave!!!!!!! bad!!!!!!!!
*
* @param string $sessionToken Misuzu session token.
* @param string $userAgent Application user agent.
* @return FlashiiClient
*/
public static function withMisuzuToken(
#[\SensitiveParameter]
string $sessionToken,
string $userAgent = ''
): self {
return new FlashiiClient(
$userAgent,
new MisuzuCredentials($sessionToken)
);
}
/**
* Creates a HTTP client instance.
*
* Will use the cURL implementation if the extension is loaded, otherwise it will fall back to PHP streams.
*
* @param string $userAgent User-Agent string to use in requests.
* @return HttpClient
*/
public static function createHttpClient(string $userAgent): HttpClient {
$userAgent = trim(sprintf('%s %s', trim($userAgent), self::userAgentString()));
try {
if(extension_loaded('curl'))
return new CurlHttpClient($userAgent);
} catch(RuntimeException $ex) {
// fall through to the stream implementation if cURL fails to initialise for some reason
}
return new StreamHttpClient($userAgent);
}
/**
* Returns the User-Agent string for the client library.
*
* If you're writing a custom HttpClient, please make sure to include this!
*
* @return string
*/
public static function userAgentString(): string {
return sprintf('ApiiPHP/%s', self::version());
}
/**
* Gets the current version of the Flashii API client library.
*
* Reads the VERSION file in the root of the Flashii directory.
* Returns 0.0.0 if reading the file failed for any reason.
*
* @return string
*/
public static function version(): string {
$version = file_get_contents(self::LIB_PATH_VERSION);
if($version === false || trim($version) === '')
return '0.0.0';
return trim($version);
}
}

85
src/FlashiiUrls.php Normal file
View file

@ -0,0 +1,85 @@
<?php declare(strict_types=1);
// FlashiiUrls.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii;
/**
* Defines the base URLs the client has to be aware of to use the API.
*/
final class FlashiiUrls {
/**
* Production API base URL.
*
* @var string
*/
public const PROD_API_URL = 'https://api.flashii.net';
/**
* Production ID service base URL.
*
* @var string
*/
public const PROD_ID_URL = 'https://id.flashii.net';
/**
* @param string $apiUrl API base URL, without trailing /.
* @param string $idUrl ID service base URL, without trailing /.
*/
public function __construct(
private string $apiUrl,
private string $idUrl
) {}
/**
* @param string $url
* @param string $path
* @param array<string, mixed> $args
* @return string
*/
private static function buildUrl(string $url, string $path, array $args): string {
$url .= $path;
if(!empty($args)) {
$url .= str_contains($url, '?') ? '&' : '?';
$url .= http_build_query($args, encoding_type: PHP_QUERY_RFC3986);
}
return $url;
}
/**
* Retrieves the API base URL.
*
* @param string $path Path to append to the URL.
* @param array<string, mixed> $args Query arguments to append to the URL.
* @return string
*/
public function getApiUrl(string $path = '', array $args = []): string {
return self::buildUrl($this->apiUrl, $path, $args);
}
/**
* Retrieves the ID service base URL.
*
* @param string $path Path to append to the URL.
* @param array<string, mixed> $args Query arguments to append to the URL.
* @return string
*/
public function getIdUrl(string $path = '', array $args = []): string {
return self::buildUrl($this->idUrl, $path, $args);
}
/**
* Creates an instance with the production URLs preset.
*
* @return FlashiiUrls
*/
public static function production(): self {
return new static(
self::PROD_API_URL,
self::PROD_ID_URL
);
}
}

View file

@ -0,0 +1,83 @@
<?php declare(strict_types=1);
// CurlHttpClient.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Http\Curl;
use CurlHandle;
use InvalidArgumentException;
use RuntimeException;
use Flashii\Http\{HttpClient,HttpRequest,HttpResponse};
/**
* cURL extension backed HTTP client implementation.
*/
class CurlHttpClient implements HttpClient {
private CurlHandle $handle;
/**
* @param string $userAgent User-Agent string.
* @throws RuntimeException If cURL initialisation failed.
*/
public function __construct(
private string $userAgent
) {
$handle = curl_init();
if($handle === false)
throw new RuntimeException('Failed to initialise cURL.');
$version = curl_version();
if(!is_array($version) || !array_key_exists('version', $version) || !is_string($version['version']))
throw new RuntimeException('Failed to retrieve cURL version info.');
$this->handle = $handle;
$this->userAgent = trim($userAgent) . sprintf(' cURL/%s', $version['version']);
}
public function __destruct() {
curl_close($this->handle);
}
public function createRequest(string $method, string $url): HttpRequest {
return new CurlHttpRequest($this->userAgent, $method, $url);
}
public function sendRequest(HttpRequest $request): HttpResponse {
if(!($request instanceof CurlHttpRequest))
throw new InvalidArgumentException('Type of $request is not understood by this HttpClient.');
curl_reset($this->handle);
curl_setopt_array($this->handle, $request->getOpts());
$response = curl_exec($this->handle);
if($response === false)
throw new RuntimeException(curl_error($this->handle), curl_errno($this->handle));
if($response === true)
throw new RuntimeException('Request executed successfully but cURL returned no data???');
$parts = explode("\r\n\r\n", $response, 2);
$headerLines = array_reverse(explode("\r\n", $parts[0]));
$statusLine = array_pop($headerLines);
if(!is_string($statusLine))
throw new RuntimeException('Unable to read status header.');
$statusLineScan = sscanf($statusLine, 'HTTP/%s %d %[^\t\r\n]');
if($statusLineScan === null)
throw new RuntimeException('Failed to decode HTTP status line.');
[$_, $statusCode, $statusLine] = $statusLineScan;
$headers = [];
while(($headerLine = array_pop($headerLines)) !== null) {
$headerLine = trim((string)$headerLine);
if($headerLine === '')
continue;
$headerParts = explode(':', $headerLine, 2);
$headers[strtolower(trim($headerParts[0]))] = count($headerParts) > 1 ? trim($headerParts[1]) : '';
}
return new CurlHttpResponse($statusCode ?? 0, $statusLine ?? '', $headers, count($parts) > 1 ? $parts[1] : '');
}
}

View file

@ -0,0 +1,73 @@
<?php declare(strict_types=1);
// CurlHttpRequest.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Http\Curl;
use Flashii\Http\HttpRequest;
/**
* Provides a cURL extension backed HTTP request implementation.
*/
class CurlHttpRequest implements HttpRequest {
/** @var array<string, string> */
private array $headers = [];
private ?string $body = null;
/**
* @param string $userAgent User-Agent string.
* @param string $method Request method.
* @param string $url Request URI.
*/
public function __construct(
private string $userAgent,
private string $method,
private string $url
) {}
public function setHeader(string $name, string $value): void {
$this->headers[strtolower($name)] = sprintf('%s: %s', $name, $value);
}
public function removeHeader(string $name): void {
unset($this->headers[strtolower($name)]);
}
public function setBody(string $body, string $contentType = 'application/octet-stream'): void {
$this->setHeader('content-type', $contentType);
$this->body = $body;
}
public function setBodyParams(array $params): void {
$this->setBody(
http_build_query($params, encoding_type: PHP_QUERY_RFC3986),
'application/x-www-form-urlencoded'
);
}
/**
* Converts this request to an array of CURLOPT_* settings.
*
* @return array<int, mixed>
*/
public function getOpts(): array {
return [
CURLOPT_AUTOREFERER => false,
CURLOPT_CUSTOMREQUEST => $this->method,
CURLOPT_FAILONERROR => false,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => $this->headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TCP_FASTOPEN => true,
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_MAXREDIRS => 5,
CURLOPT_POSTFIELDS => $this->body,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_TIMEOUT => 5,
CURLOPT_URL => $this->url,
CURLOPT_USERAGENT => $this->userAgent,
];
}
}

View file

@ -0,0 +1,55 @@
<?php declare(strict_types=1);
// CurlHttpResponse.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Http\Curl;
use Flashii\Http\{HttpResponse,JsonBody,SuccessStatusCode};
/**
* cURL extension backed HTTP response.
*/
class CurlHttpResponse implements HttpResponse {
use JsonBody, SuccessStatusCode;
/**
* @param int $statusCode HTTP status code.
* @param string $statusText HTTP status text.
* @param array<string, string> $headers HTTP response headers.
* @param string $body HTTP body.
*/
public function __construct(
private int $statusCode,
private string $statusText,
private array $headers,
private string $body
) {}
public function getStatusCode(): int {
return $this->statusCode;
}
public function getStatusText(): string {
return $this->statusText;
}
public function getHeaders(): array {
return $this->headers;
}
public function hasHeader(string $name): bool {
return array_key_exists(strtolower($name), $this->headers);
}
public function getHeader(string $name): string {
return $this->headers[strtolower($name)] ?? '';
}
public function hasBody(): bool {
return $this->body !== '';
}
public function getBody(): string {
return $this->body;
}
}

34
src/Http/HttpClient.php Normal file
View file

@ -0,0 +1,34 @@
<?php declare(strict_types=1);
// HttpClient.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Http;
use InvalidArgumentException;
use RuntimeException;
/**
* Provides a common interface for making HTTP clients.
*/
interface HttpClient {
/**
* Creates a HTTP request object.
*
* @param string $method Desired request method.
* @param string $url Target URL.
* @throws RuntimeException If the request could not be initialised.
* @return HttpRequest
*/
function createRequest(string $method, string $url): HttpRequest;
/**
* Sends a HTTP request and returns a HTTP response.
*
* @param HttpRequest $request Request to send.
* @throws InvalidArgumentException If $request is not understood by the HttpClient implementation.
* @throws RuntimeException If the HTTP request failed in an unexpected way.
* @return HttpResponse
*/
function sendRequest(HttpRequest $request): HttpResponse;
}

41
src/Http/HttpRequest.php Normal file
View file

@ -0,0 +1,41 @@
<?php declare(strict_types=1);
// HttpRequest.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Http;
/**
* Provides a common interface for HTTP requests.
*/
interface HttpRequest {
/**
* Sets a header value.
*
* @param string $name Header name.
* @param string $value Header value.
*/
function setHeader(string $name, string $value): void;
/**
* Removes a header value.
*
* @param string $name.
*/
function removeHeader(string $name): void;
/**
* Sets a raw body with a provided media type.
*
* @param string $body Request body.
* @param string $contentType Request body content type.
*/
function setBody(string $body, string $contentType = 'application/octet-stream'): void;
/**
* Sets a urlencoded form request body.
*
* @param array<string, mixed> $params Form parameters.
*/
function setBodyParams(array $params): void;
}

85
src/Http/HttpResponse.php Normal file
View file

@ -0,0 +1,85 @@
<?php declare(strict_types=1);
// HttpResponse.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Http;
use RuntimeException;
/**
* Provides a common interface for HTTP responses.
*/
interface HttpResponse {
/**
* Returns the status code.
*
* @return int
*/
function getStatusCode(): int;
/**
* Returns the status text.
*
* @return string
*/
function getStatusText(): string;
/**
* Returns true if the status code indicates success (200-299).
*
* @return bool
*/
function isSuccessStatusCode(): bool;
/**
* Throws an exception if isSuccessStatusCode was false.
*
* @throws RuntimeException if the status code did not indicate success.
*/
function ensureSuccessStatusCode(): void;
/**
* Returns all response headers.
*
* @return array<string, string>
*/
function getHeaders(): array;
/**
* Checks if a response header is present.
*
* @param string $name Name of the header.
* @return bool true if it is present, false if not.
*/
function hasHeader(string $name): bool;
/**
* Returns the value of a response header.
*
* @param string $name Name of the header.
* @return string
*/
function getHeader(string $name): string;
/**
* Checks if the response has a body.
*
* @return bool true if it has a body, false if not.
*/
function hasBody(): bool;
/**
* Returns the body of this response.
*
* @return string
*/
function getBody(): string;
/**
* Attempts to json_decode the body string.
*
* @param bool $associative true to return JSON objects as PHP arrays instead of PHP objects.
* @return mixed
*/
function getJsonBody(bool $associative = true): mixed;
}

22
src/Http/JsonBody.php Normal file
View file

@ -0,0 +1,22 @@
<?php declare(strict_types=1);
// JsonBody.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Http;
use RuntimeException;
/**
* Common implementation for the getJsonBody method of HttpResponse.
*/
trait JsonBody {
/**
* Attempts to json_decode the body string.
*
* @param bool $associative true to return JSON objects as PHP arrays instead of PHP objects.
* @return mixed
*/
public function getJsonBody(bool $associative = false): mixed {
return json_decode($this->getBody(), $associative, 512, JSON_INVALID_UTF8_SUBSTITUTE);
}
}

View file

@ -0,0 +1,73 @@
<?php declare(strict_types=1);
// StreamHttpClient.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Http\Stream;
use InvalidArgumentException;
use RuntimeException;
use Flashii\Http\{HttpClient,HttpRequest,HttpResponse};
/**
* PHP stream backed HTTP client implementation.
*/
class StreamHttpClient implements HttpClient {
/**
* @param string $userAgent User-Agent string.
*/
public function __construct(
private string $userAgent
) {}
public function createRequest(string $method, string $url): HttpRequest {
return new StreamHttpRequest($this->userAgent, $method, $url);
}
public function sendRequest(HttpRequest $request): HttpResponse {
if(!($request instanceof StreamHttpRequest))
throw new InvalidArgumentException('Type of $request is not understood by this HttpClient.');
$ctx = stream_context_create($request->getStreamOptions());
$handle = fopen($request->getUrl(), 'rb', context: $ctx);
if($handle === false)
throw new RuntimeException('Failed create HTTP request.');
$metaData = stream_get_meta_data($handle);
if(!array_key_exists('wrapper_data', $metaData) || !is_array($metaData['wrapper_data']))
throw new RuntimeException('wrapper_data missing from stream metadata.');
$wrapperData = array_reverse($metaData['wrapper_data']);
$statusLine = array_pop($wrapperData);
if(!is_string($statusLine))
throw new RuntimeException('Unable to read status header.');
$statusLineScan = sscanf($statusLine, 'HTTP/%s %d %[^\t\r\n]');
if($statusLineScan === null)
throw new RuntimeException('Failed to decode HTTP status line.');
[$_, $statusCode, $statusLine] = $statusLineScan;
$headers = [];
while(($headerLine = array_pop($wrapperData)) !== null) {
if(!is_string($headerLine)) {
if(is_scalar($headerLine))
$headerLine = (string)$headerLine;
else continue;
}
$headerLine = trim($headerLine);
if($headerLine === '')
continue;
$headerParts = explode(':', $headerLine, 2);
$headers[strtolower(trim($headerParts[0]))] = count($headerParts) > 1 ? trim($headerParts[1]) : '';
}
$body = stream_get_contents($handle);
if($body === false)
throw new RuntimeException('Request executed successfully but failed to retrieve data.');
return new StreamHttpResponse($statusCode ?? 0, $statusLine ?? '', $headers, $body);
}
}

View file

@ -0,0 +1,88 @@
<?php declare(strict_types=1);
// StreamHttpRequest.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Http\Stream;
use Flashii\Http\HttpRequest;
/**
* Provides a builder for PHP stream backed HTTP requests.
*/
class StreamHttpRequest implements HttpRequest {
/** @var array<string, string> */
private array $headers = [];
private ?string $body = null;
/**
* @param string $userAgent User-Agent string.
* @param string $method Request method.
* @param string $url Request URI.
*/
public function __construct(
private string $userAgent,
private string $method,
private string $url
) {}
public function setHeader(string $name, string $value): void {
$this->headers[strtolower($name)] = sprintf('%s: %s', $name, $value);
}
public function removeHeader(string $name): void {
unset($this->headers[strtolower($name)]);
}
public function setBody(string $body, string $contentType = 'application/octet-stream'): void {
$this->setHeader('content-type', $contentType);
$this->body = $body;
}
public function setBodyParams(array $params): void {
$this->setBody(
http_build_query($params, encoding_type: PHP_QUERY_RFC3986),
'application/x-www-form-urlencoded'
);
}
/**
* Returns the request URI.
*
* @return string
*/
public function getUrl(): string {
return $this->url;
}
/**
* Converts this request to an array that can be passed to stream_context_create.
*
* @return array{'http': array{
* method: string,
* header: array<string, string>,
* user_agent: string,
* content: ?string,
* follow_location: int,
* max_redirects: int,
* protocol_version: string,
* timeout: float,
* ignore_errors: bool
* }}
*/
public function getStreamOptions(): array {
return [
'http' => [
'method' => $this->method,
'header' => $this->headers,
'user_agent' => $this->userAgent,
'content' => $this->body,
'follow_location' => 1,
'max_redirects' => 5,
'protocol_version' => '1.1',
'timeout' => 7,
'ignore_errors' => true,
],
];
}
}

View file

@ -0,0 +1,55 @@
<?php declare(strict_types=1);
// StreamHttpResponse.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Http\Stream;
use Flashii\Http\{HttpResponse,JsonBody,SuccessStatusCode};
/**
* PHP stream backed HTTP response.
*/
class StreamHttpResponse implements HttpResponse {
use JsonBody, SuccessStatusCode;
/**
* @param int $statusCode HTTP status code.
* @param string $statusText HTTP status text.
* @param array<string, string> $headers HTTP response headers.
* @param string $body HTTP body.
*/
public function __construct(
private int $statusCode,
private string $statusText,
private array $headers,
private string $body
) {}
public function getStatusCode(): int {
return $this->statusCode;
}
public function getStatusText(): string {
return $this->statusText;
}
public function getHeaders(): array {
return $this->headers;
}
public function hasHeader(string $name): bool {
return array_key_exists(strtolower($name), $this->headers);
}
public function getHeader(string $name): string {
return $this->headers[strtolower($name)] ?? '';
}
public function hasBody(): bool {
return $this->body !== '';
}
public function getBody(): string {
return $this->body;
}
}

View file

@ -0,0 +1,32 @@
<?php declare(strict_types=1);
// SuccessStatusCode.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\Http;
use RuntimeException;
/**
* Common implementation for the SuccessStatusCode methods of HttpResponse.
*/
trait SuccessStatusCode {
/**
* Returns true if the status code indicates success (200-299).
*
* @return bool
*/
public function isSuccessStatusCode(): bool {
$statusCode = $this->getStatusCode();
return $statusCode >= 200 && $statusCode <= 299;
}
/**
* Throws an exception if isSuccessStatusCode was false.
*
* @throws RuntimeException if the status code did not indicate success.
*/
public function ensureSuccessStatusCode(): void {
if(!$this->isSuccessStatusCode())
throw new RuntimeException(sprintf('HTTP request returned status code %03d.', $this->getStatusCode()));
}
}

View file

@ -0,0 +1,69 @@
<?php declare(strict_types=1);
// OAuth2AuthorizationRequest.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\OAuth2;
/**
* OAuth2 authorisation request response.
*/
final class OAuth2AuthorizationRequest {
/**
* @param string $deviceCode Authorisation request code.
* @param string $userCode Code for user entry.
* @param string $verificationUri Verification URI sans code.
* @param string $verificationUriComplete Verification URI but with code.
* @param int $expiresIn Amount of seconds until the thing expires.
* @param int $interval Interval of seconds to check at.
*/
public function __construct(
private string $deviceCode,
private string $userCode,
private string $verificationUri,
private string $verificationUriComplete,
private int $expiresIn,
private int $interval
) {}
public function getDeviceCode(): string {
return $this->deviceCode;
}
public function getUserCode(): string {
return $this->userCode;
}
public function getVerificationUri(): string {
return $this->verificationUri;
}
public function getVerificationUriComplete(): string {
return $this->verificationUriComplete;
}
public function getExpiresIn(): int {
return $this->expiresIn;
}
public function getInterval(): int {
return $this->interval;
}
/**
* Decodes an object into this proper object.
*
* @param object $info
* @return OAuth2AuthorizationRequest
*/
public static function decode(object $info): self {
return new static(
isset($info->device_code) && is_string($info->device_code) ? $info->device_code : '',
isset($info->user_code) && is_string($info->user_code) ? $info->user_code : '',
isset($info->verification_uri) && is_string($info->verification_uri) ? $info->verification_uri : '',
isset($info->verification_uri_complete) && is_string($info->verification_uri_complete) ? $info->verification_uri_complete : '',
isset($info->expires_in) && is_int($info->expires_in) ? $info->expires_in : 600,
isset($info->interval) && is_int($info->interval) ? $info->interval : 5
);
}
}

273
src/OAuth2/OAuth2Client.php Normal file
View file

@ -0,0 +1,273 @@
<?php declare(strict_types=1);
// OAuth2Client.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\OAuth2;
use RuntimeException;
use InvalidArgumentException;
use Flashii\{AuthorizedHttpClient,FlashiiClient,FlashiiUrls,UriBase64};
use Flashii\Credentials\{BasicCredentials,Credentials};
/**
* Implements the OAuth2 portions of the API.
*/
class OAuth2Client {
/**
* @param FlashiiClient $flashiiClient Parent client instance to fork upon successful auth.
* @param AuthorizedHttpClient $httpClient Authorized HTTP client instance.
* @param Credentials $credentials API credentials.
* @param FlashiiUrls $urls API URLs.
*/
public function __construct(
private FlashiiClient $flashiiClient,
private AuthorizedHttpClient $httpClient,
private Credentials $credentials,
private FlashiiUrls $urls
) {}
/**
* Builds an OAuth2 authorisation URL to redirect the user to.
*
* @param string $codeVerifier Code verifier to hash and encode as a code challenge.
* @param ?string $state State variable, null to omit.
* @param ?string $scope Scope to request, null to omit.
* @param ?string $redirectUri URI to redirect to, required if the app has more than one redirect URI registered.
* @param ?string $clientId Client ID of the app attempting to authorise.
* @param array<string, mixed> $args Additional arguments to add to the URL.
* @throws InvalidArgumentException If the Credentials instance passed to the constructor is not an instance of BasicCredentials and $clientId was left as null.
* @return string Authorisation URL that the user can be redirected to.
*/
public function createAuthorizeUrl(
string $codeVerifier,
?string $state = null,
?string $scope = null,
?string $redirectUri = null,
?string $clientId = null,
array $args = []
): string {
if($clientId === null) {
if($this->credentials instanceof BasicCredentials)
$clientId = $this->credentials->getUserName();
else
throw new InvalidArgumentException('$clientId may not be null if $this->credentials is not an instance of BasicCredentials.');
}
$args['response_type'] = 'code';
$args['client_id'] = $clientId;
$args['code_challenge'] = UriBase64::encode(hash('sha256', $codeVerifier, true));
$args['code_challenge_method'] = 'S256';
if($state !== null)
$args['state'] = $state;
if($scope !== null)
$args['scope'] = $scope;
if($redirectUri !== null)
$args['redirect_uri'] = $redirectUri;
return $this->urls->getIdUrl('/oauth2/authorise', $args);
}
/**
* Request device authorisation.
*
* @param ?string $scope Scope to request, null to omit.
* @param ?string $clientId Client ID, if Basic auth is impossible for some reason.
* @return OAuth2Error|OAuth2AuthorizationRequest
*/
public function requestAuthorise(
?string $scope = null,
?string $clientId = null
): OAuth2Error|OAuth2AuthorizationRequest {
$args = [];
if($scope !== null)
$args['scope'] = $scope;
$useAnonymous = false;
if($clientId !== null) {
$useAnonymous = true;
$args['client_id'] = $clientId;
}
$request = $this->httpClient->createRequest('POST', '/oauth2/request-authorise');
$request->setBodyParams($args);
$response = $useAnonymous
? $this->httpClient->sendRequestAnonymous($request)
: $this->httpClient->sendRequest($request);
$body = $response->getJsonBody();
if(!is_object($body))
throw new RuntimeException('API response was not a JSON object.');
if(isset($body->error))
return OAuth2Error::decode($body);
return OAuth2AuthorizationRequest::decode($body);
}
/**
* Alias for requestAuthorise.
*
* @param ?string $scope See requestAuthorise documentation.
* @param ?string $clientId See requestAuthorise documentation.
* @return OAuth2Error|OAuth2AuthorizationRequest
*/
public function requestAuthorize(
?string $scope = null,
?string $clientId = null
): OAuth2Error|OAuth2AuthorizationRequest {
return $this->requestAuthorise($scope, $clientId);
}
/**
* Attempts to request an access token.
*
* @param string $grantType Grant type used to attempt to create an access token.
* @param array<string, string> $args Additional arguments for the grant.
* @param ?string $clientId Client ID, if Basic auth is impossible for some reason.
* @param ?string $clientSecret Client Secret, if Basic auth is impossible for some reason.
* @return OAuth2Error|OAuth2Token
*/
public function token(
string $grantType,
array $args,
?string $clientId = null,
#[\SensitiveParameter]
?string $clientSecret = null
): OAuth2Error|OAuth2Token {
$args['grant_type'] = $grantType;
$useAnonymous = false;
if($clientId !== null) {
$useAnonymous = true;
$args['client_id'] = $clientId;
if($clientSecret !== null)
$args['client_secret'] = $clientSecret;
}
$request = $this->httpClient->createRequest('POST', '/oauth2/token');
$request->setBodyParams($args);
$response = $useAnonymous
? $this->httpClient->sendRequestAnonymous($request)
: $this->httpClient->sendRequest($request);
$body = $response->getJsonBody();
if(!is_object($body))
throw new RuntimeException('API response was not a JSON object.');
if(isset($body->error))
return OAuth2Error::decode($body);
return OAuth2Token::decode($body);
}
/**
* Attempts to request an access token using an authorization code.
*
* @param string $code Authorization code received from the client.
* @param string $codeVerifier Code verifier received from the client.
* @param ?string $clientId Client ID, if Basic auth is impossible for some reason.
* @param ?string $clientSecret Client Secret, if Basic auth is impossible for some reason.
* @return OAuth2Error|OAuth2Token
*/
public function tokenWithAuthorizationCode(
#[\SensitiveParameter]
string $code,
#[\SensitiveParameter]
string $codeVerifier,
?string $clientId = null,
#[\SensitiveParameter]
?string $clientSecret = null
): OAuth2Error|OAuth2Token {
return $this->token(
'authorization_code',
[
'code' => $code,
'code_verifier' => $codeVerifier,
],
$clientId,
$clientSecret
);
}
/**
* Attempts to request a new access token using a refresh token.
*
* @param string $refreshToken Refresh token received in previous access token request.
* @param ?string $scope Adjusted scope, null to leave unchanged.
* @param ?string $clientId Client ID, if Basic auth is impossible for some reason.
* @param ?string $clientSecret Client Secret, if Basic auth is impossible for some reason.
* @return OAuth2Error|OAuth2Token
*/
public function tokenWithRefreshToken(
#[\SensitiveParameter]
string $refreshToken,
?string $scope = null,
?string $clientId = null,
#[\SensitiveParameter]
?string $clientSecret = null
): OAuth2Error|OAuth2Token {
$args = ['refresh_token' => $refreshToken];
if($scope !== null)
$args['scope'] = $scope;
return $this->token(
'refresh_token',
$args,
$clientId,
$clientSecret
);
}
/**
* Attempts to request an app access token.
*
* @param ?string $scope Desired scope.
* @param ?string $clientId Client ID, if Basic auth is impossible for some reason.
* @param ?string $clientSecret Client Secret, if Basic auth is impossible for some reason.
* @return OAuth2Error|OAuth2Token
*/
public function tokenWithClientCredentials(
?string $scope = null,
?string $clientId = null,
#[\SensitiveParameter]
?string $clientSecret = null
): OAuth2Error|OAuth2Token {
$args = [];
if($scope !== null)
$args['scope'] = $scope;
return $this->token(
'client_credentials',
$args,
$clientId,
$clientSecret
);
}
/**
* Attempts to request an access token using a device code.
*
* @param string $deviceCode Device code.
* @param ?string $clientId Client ID, if Basic auth is impossible for some reason.
* @param ?string $clientSecret Client Secret, if Basic auth is impossible for some reason.
* @return OAuth2Error|OAuth2Token
*/
public function tokenWithDeviceCode(
string $deviceCode,
?string $clientId = null,
#[\SensitiveParameter]
?string $clientSecret = null
): OAuth2Error|OAuth2Token {
return $this->token(
'urn:ietf:params:oauth:grant-type:device_code',
['device_code' => $deviceCode],
$clientId,
$clientSecret
);
}
}

View file

@ -0,0 +1,63 @@
<?php declare(strict_types=1);
// OAuth2Error.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\OAuth2;
/**
* OAuth2 error response.
*/
final class OAuth2Error {
/**
* @param string $error Error code.
* @param string $description Error description.
* @param string $uri Error URI.
*/
public function __construct(
private string $error,
private string $description,
private string $uri
) {}
/**
* Returns the OAuth2 error code.
*
* @return string
*/
public function getCode(): string {
return $this->error;
}
/**
* Returns the OAuth2 error description.
*
* @return string
*/
public function getDescription(): string {
return $this->description;
}
/**
* Returns the OAuth2 error documentation URI.
*
* @return string
*/
public function getUri(): string {
return $this->uri;
}
/**
* Decodes an object into this proper object.
*
* @param object $info
* @return OAuth2Error
*/
public static function decode(object $info): self {
return new static(
isset($info->error) && is_string($info->error) ? $info->error : '',
isset($info->error_description) && is_string($info->error_description) ? $info->error_description : '',
isset($info->error_uri) && is_string($info->error_uri) ? $info->error_uri : ''
);
}
}

View file

@ -0,0 +1,63 @@
<?php declare(strict_types=1);
// OAuth2Token.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii\OAuth2;
use Flashii\Credentials\BearerCredentials;
/**
* OAuth2 token response.
*/
final class OAuth2Token {
/**
* @param string $accessToken Access token.
* @param string $tokenType Token type.
* @param int $expiresIn Seconds until the token expires.
* @param string $scope Scope of the token.
* @param string $refreshToken Refresh token.
*/
public function __construct(
private string $accessToken,
private string $tokenType,
private int $expiresIn,
private string $scope,
private string $refreshToken
) {}
public function getAccessToken(): string {
return $this->accessToken;
}
public function getTokenType(): string {
return $this->tokenType;
}
public function getExpiresIn(): int {
return $this->expiresIn;
}
public function getScope(): string {
return $this->scope;
}
public function getRefreshToken(): string {
return $this->refreshToken;
}
/**
* Decodes an object into this proper object.
*
* @param object $info
* @return OAuth2Token
*/
public static function decode(object $info): self {
return new static(
isset($info->access_token) && is_string($info->access_token) ? $info->access_token : '',
isset($info->token_type) && is_string($info->token_type) ? $info->token_type : 'Bearer',
isset($info->expires_in) && is_int($info->expires_in) ? $info->expires_in : 3600,
isset($info->scope) && is_string($info->scope) ? $info->scope : '',
isset($info->refresh_token) && is_string($info->refresh_token) ? $info->refresh_token : ''
);
}
}

32
src/UriBase64.php Normal file
View file

@ -0,0 +1,32 @@
<?php declare(strict_types=1);
// UriBase64.php
// Created: 2024-11-16
// Updated: 2024-11-16
namespace Flashii;
/**
* Provides URL-safe Base64 encoding.
*/
final class UriBase64 {
/**
* Encodes data with URI-safe MIME base64.
*
* @param string $string The data to encode.
* @return string The encoded data, as a string.
*/
public static function encode(string $string): string {
return rtrim(strtr(base64_encode($string), '+/', '-_'), '=');
}
/**
* Decodes data encoded with URI-safe MIME base64.
*
* @param string $string The encoded data.
* @param bool $strict If the strict parameter is set to true then the base64_decode() function will return false if the input contains character from outside the base64 alphabet. Otherwise invalid characters will be silently discarded.
* @return string|false Returns the decoded data or false on failure. The returned data may be binary.
*/
public static function decode(string $string, bool $strict = false): string|false {
return base64_decode(str_pad(strtr($string, '-_', '+/'), strlen($string) % 4, '=', STR_PAD_RIGHT));
}
}

179
tests/CurlHttpTest.php Normal file
View file

@ -0,0 +1,179 @@
<?php declare(strict_types=1);
// CurlHttpTest.php
// Created: 2024-11-16
// Updated: 2024-11-16
use PHPUnit\Framework\TestCase;
use Flashii\{AuthorizedHttpClient,FlashiiClient};
use Flashii\Credentials\{BasicCredentials,BearerCredentials};
use Flashii\Http\Curl\{CurlHttpClient,CurlHttpRequest,CurlHttpResponse};
/**
* @covers CurlHttpClient
* @covers CurlHttpRequest
* @covers CurlHttpResponse
* @uses AuthorizedHttpClient
* @uses FlashiiClient
* @uses BasicCredentials
* @uses BearerCredentials
*/
final class CurlHttpTest extends TestCase {
public function testHttpGet(): void {
$client = new CurlHttpClient(FlashiiClient::userAgentString());
$request = $client->createRequest('GET', 'https://httpbin.org/get?alreadyhere=true');
$request->setHeader('X-Test', 'meow');
$request->setHeader('X-Test2', 'mewow');
$request->removeHeader('x-test2');
$response = $client->sendRequest($request);
$this->assertEquals(200, $response->getStatusCode());
$this->assertTrue($response->hasHeader('Content-Type'));
$this->assertEquals('application/json', $response->getHeader('Content-type'));
$this->assertTrue($response->hasBody());
$body = $response->getJsonBody();
$this->assertIsObject($body);
$body = $response->getJsonBody(true);
$this->assertIsArray($body);
$this->assertArrayHasKey('headers', $body);
$this->assertIsArray($body['headers']);
$this->assertArrayHasKey('X-Test', $body['headers']);
$this->assertEquals('meow', $body['headers']['X-Test']);
$this->assertArrayNotHasKey('X-Test2', $body['headers']);
$this->assertArrayHasKey('args', $body);
$this->assertIsArray($body['args']);
$this->assertArrayHasKey('alreadyhere', $body['args']);
$this->assertEquals('true', $body['args']['alreadyhere']);
}
public function testHttpFailStatus(): void {
$client = new CurlHttpClient(FlashiiClient::userAgentString());
$request = $client->createRequest('GET', 'https://httpbin.org/status/404');
$response = $client->sendRequest($request);
$this->assertEquals(404, $response->getStatusCode());
$request = $client->createRequest('POST', 'https://httpbin.org/status/503');
$response = $client->sendRequest($request);
$this->assertEquals(503, $response->getStatusCode());
}
public function testEnsureSuccess(): void {
$client = new CurlHttpClient(FlashiiClient::userAgentString());
$request = $client->createRequest('PATCH', 'https://httpbin.org/status/204');
$response = $client->sendRequest($request);
$this->assertTrue($response->isSuccessStatusCode());
$response->ensureSuccessStatusCode();
$request = $client->createRequest('DELETE', 'https://httpbin.org/status/418');
$response = $client->sendRequest($request);
$this->assertFalse($response->isSuccessStatusCode());
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('HTTP request returned status code 418.');
$response->ensureSuccessStatusCode();
}
public function testAuthorizedHttp(): void {
$realClient = new CurlHttpClient(FlashiiClient::userAgentString());
$client = new AuthorizedHttpClient($realClient, new BasicCredentials('freakyfurball', 'express1'));
$this->assertFalse(
$realClient->sendRequest(
$realClient->createRequest('GET', 'https://httpbin.org/basic-auth/freakyfurball/express1')
)->isSuccessStatusCode()
);
$this->assertTrue(
$client->sendRequest(
$client->createRequest('GET', 'https://httpbin.org/basic-auth/freakyfurball/express1')
)->isSuccessStatusCode()
);
$this->assertFalse(
$client->sendRequest(
$client->createRequest('GET', 'https://httpbin.org/basic-auth/freakyfurball/express2')
)->isSuccessStatusCode()
);
$this->assertFalse(
$realClient->sendRequest(
$realClient->createRequest('GET', 'https://httpbin.org/bearer')
)->isSuccessStatusCode()
);
$accessToken = 'DSw7ih2fMfXW3fAkUKJitltBtc7JT8TA';
$client = new AuthorizedHttpClient($realClient, new BearerCredentials($accessToken));
$response = $client->sendRequest(
$client->createRequest('GET', 'https://httpbin.org/bearer')
);
$this->assertTrue($response->hasBody());
$body = $response->getJsonBody(true);
$this->assertIsArray($body);
$this->assertArrayHasKey('authenticated', $body);
$this->assertIsBool($body['authenticated']);
$this->assertTrue($body['authenticated']);
$this->assertArrayHasKey('token', $body);
$this->assertIsString($body['token']);
$this->assertEquals($accessToken, $body['token']);
}
public function testHttpBody(): void {
$client = new CurlHttpClient(FlashiiClient::userAgentString());
$random = base64_encode(random_bytes(100));
$request = $client->createRequest('PUT', 'https://httpbin.org/anything');
$request->setBody($random, 'application/x-test-data');
$response = $client->sendRequest($request);
$this->assertTrue($response->hasBody());
$body = $response->getJsonBody(true);
$this->assertIsArray($body);
$this->assertArrayHasKey('method', $body);
$this->assertIsString($body['method']);
$this->assertEquals('PUT', $body['method']);
$this->assertArrayHasKey('headers', $body);
$this->assertIsArray($body['headers']);
$this->assertArrayHasKey('Content-Type', $body['headers']);
$this->assertIsString($body['headers']['Content-Type']);
$this->assertEquals('application/x-test-data', $body['headers']['Content-Type']);
$this->assertArrayHasKey('data', $body);
$this->assertIsString($body['data']);
$this->assertEquals($random, $body['data']);
$request = $client->createRequest('POST', 'https://httpbin.org/post');
$request->setBodyParams([
'string' => 'meow',
'more' => 'wowzers this one has spaces crazy',
]);
$response = $client->sendRequest($request);
$this->assertTrue($response->hasBody());
$body = $response->getJsonBody(true);
$this->assertIsArray($body);
$this->assertArrayHasKey('headers', $body);
$this->assertIsArray($body['headers']);
$this->assertArrayHasKey('Content-Type', $body['headers']);
$this->assertIsString($body['headers']['Content-Type']);
$this->assertEquals('application/x-www-form-urlencoded', $body['headers']['Content-Type']);
$this->assertArrayHasKey('form', $body);
$this->assertIsArray($body['form']);
$this->assertArrayHasKey('string', $body['form']);
$this->assertIsString($body['form']['string']);
$this->assertEquals('meow', $body['form']['string']);
$this->assertArrayHasKey('more', $body['form']);
$this->assertIsString($body['form']['more']);
$this->assertEquals('wowzers this one has spaces crazy', $body['form']['more']);
}
}

179
tests/StreamHttpTest.php Normal file
View file

@ -0,0 +1,179 @@
<?php declare(strict_types=1);
// StreamHttpTest.php
// Created: 2024-11-16
// Updated: 2024-11-16
use PHPUnit\Framework\TestCase;
use Flashii\{AuthorizedHttpClient,FlashiiClient};
use Flashii\Credentials\{BasicCredentials,BearerCredentials};
use Flashii\Http\Stream\{StreamHttpClient,StreamHttpRequest,StreamHttpResponse};
/**
* @covers StreamHttpClient
* @covers StreamHttpRequest
* @covers StreamHttpResponse
* @uses AuthorizedHttpClient
* @uses FlashiiClient
* @uses BasicCredentials
* @uses BearerCredentials
*/
final class StreamHttpTest extends TestCase {
public function testHttpGet(): void {
$client = new StreamHttpClient(FlashiiClient::userAgentString());
$request = $client->createRequest('GET', 'https://httpbin.org/get?alreadyhere=true');
$request->setHeader('X-Test', 'meow');
$request->setHeader('X-Test2', 'mewow');
$request->removeHeader('x-test2');
$response = $client->sendRequest($request);
$this->assertEquals(200, $response->getStatusCode());
$this->assertTrue($response->hasHeader('Content-Type'));
$this->assertEquals('application/json', $response->getHeader('Content-type'));
$this->assertTrue($response->hasBody());
$body = $response->getJsonBody();
$this->assertIsObject($body);
$body = $response->getJsonBody(true);
$this->assertIsArray($body);
$this->assertArrayHasKey('headers', $body);
$this->assertIsArray($body['headers']);
$this->assertArrayHasKey('X-Test', $body['headers']);
$this->assertEquals('meow', $body['headers']['X-Test']);
$this->assertArrayNotHasKey('X-Test2', $body['headers']);
$this->assertArrayHasKey('args', $body);
$this->assertIsArray($body['args']);
$this->assertArrayHasKey('alreadyhere', $body['args']);
$this->assertEquals('true', $body['args']['alreadyhere']);
}
public function testHttpFailStatus(): void {
$client = new StreamHttpClient(FlashiiClient::userAgentString());
$request = $client->createRequest('GET', 'https://httpbin.org/status/404');
$response = $client->sendRequest($request);
$this->assertEquals(404, $response->getStatusCode());
$request = $client->createRequest('POST', 'https://httpbin.org/status/503');
$response = $client->sendRequest($request);
$this->assertEquals(503, $response->getStatusCode());
}
public function testEnsureSuccess(): void {
$client = new StreamHttpClient(FlashiiClient::userAgentString());
$request = $client->createRequest('PATCH', 'https://httpbin.org/status/204');
$response = $client->sendRequest($request);
$this->assertTrue($response->isSuccessStatusCode());
$response->ensureSuccessStatusCode();
$request = $client->createRequest('DELETE', 'https://httpbin.org/status/418');
$response = $client->sendRequest($request);
$this->assertFalse($response->isSuccessStatusCode());
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('HTTP request returned status code 418.');
$response->ensureSuccessStatusCode();
}
public function testAuthorizedHttp(): void {
$realClient = new StreamHttpClient(FlashiiClient::userAgentString());
$client = new AuthorizedHttpClient($realClient, new BasicCredentials('freakyfurball', 'express1'));
$this->assertFalse(
$realClient->sendRequest(
$realClient->createRequest('GET', 'https://httpbin.org/basic-auth/freakyfurball/express1')
)->isSuccessStatusCode()
);
$this->assertTrue(
$client->sendRequest(
$client->createRequest('GET', 'https://httpbin.org/basic-auth/freakyfurball/express1')
)->isSuccessStatusCode()
);
$this->assertFalse(
$client->sendRequest(
$client->createRequest('GET', 'https://httpbin.org/basic-auth/freakyfurball/express2')
)->isSuccessStatusCode()
);
$this->assertFalse(
$realClient->sendRequest(
$realClient->createRequest('GET', 'https://httpbin.org/bearer')
)->isSuccessStatusCode()
);
$accessToken = 'DSw7ih2fMfXW3fAkUKJitltBtc7JT8TA';
$client = new AuthorizedHttpClient($realClient, new BearerCredentials($accessToken));
$response = $client->sendRequest(
$client->createRequest('GET', 'https://httpbin.org/bearer')
);
$this->assertTrue($response->hasBody());
$body = $response->getJsonBody(true);
$this->assertIsArray($body);
$this->assertArrayHasKey('authenticated', $body);
$this->assertIsBool($body['authenticated']);
$this->assertTrue($body['authenticated']);
$this->assertArrayHasKey('token', $body);
$this->assertIsString($body['token']);
$this->assertEquals($accessToken, $body['token']);
}
public function testHttpBody(): void {
$client = new StreamHttpClient(FlashiiClient::userAgentString());
$random = base64_encode(random_bytes(100));
$request = $client->createRequest('PUT', 'https://httpbin.org/anything');
$request->setBody($random, 'application/x-test-data');
$response = $client->sendRequest($request);
$this->assertTrue($response->hasBody());
$body = $response->getJsonBody(true);
$this->assertIsArray($body);
$this->assertArrayHasKey('method', $body);
$this->assertIsString($body['method']);
$this->assertEquals('PUT', $body['method']);
$this->assertArrayHasKey('headers', $body);
$this->assertIsArray($body['headers']);
$this->assertArrayHasKey('Content-Type', $body['headers']);
$this->assertIsString($body['headers']['Content-Type']);
$this->assertEquals('application/x-test-data', $body['headers']['Content-Type']);
$this->assertArrayHasKey('data', $body);
$this->assertIsString($body['data']);
$this->assertEquals($random, $body['data']);
$request = $client->createRequest('POST', 'https://httpbin.org/post');
$request->setBodyParams([
'string' => 'meow',
'more' => 'wowzers this one has spaces crazy',
]);
$response = $client->sendRequest($request);
$this->assertTrue($response->hasBody());
$body = $response->getJsonBody(true);
$this->assertIsArray($body);
$this->assertArrayHasKey('headers', $body);
$this->assertIsArray($body['headers']);
$this->assertArrayHasKey('Content-Type', $body['headers']);
$this->assertIsString($body['headers']['Content-Type']);
$this->assertEquals('application/x-www-form-urlencoded', $body['headers']['Content-Type']);
$this->assertArrayHasKey('form', $body);
$this->assertIsArray($body['form']);
$this->assertArrayHasKey('string', $body['form']);
$this->assertIsString($body['form']['string']);
$this->assertEquals('meow', $body['form']['string']);
$this->assertArrayHasKey('more', $body['form']);
$this->assertIsString($body['form']['more']);
$this->assertEquals('wowzers this one has spaces crazy', $body['form']['more']);
}
}

30
tools/create-tag Executable file
View file

@ -0,0 +1,30 @@
#!/usr/bin/env php
<?php
$path = (function($path) {
if(!str_starts_with($path, '/'))
die('Cannot be bothered to support non-UNIX style paths, sorry!' . PHP_EOL);
while($path !== '/') {
$vPath = $path . DIRECTORY_SEPARATOR . 'VERSION';
if(is_file($vPath))
return $vPath;
$path = dirname($path);
}
})(__DIR__);
$version = file_get_contents($path);
if($version === false)
die('Failed to read VERSION file.' . PHP_EOL);
$version = trim($version);
$workingDir = getcwd();
try {
chdir(dirname($path));
echo shell_exec(sprintf('git tag v%s', $version));
echo shell_exec(sprintf('git push origin v%s', $version));
} finally {
chdir($workingDir);
}
echo $version . PHP_EOL;

8
tools/precommit Executable file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env bash
pushd .
cd $(dirname "$0")
./update-headers
popd

173
tools/update-headers Executable file
View file

@ -0,0 +1,173 @@
#!/usr/bin/env php
<?php
// this file sucks, screws up the header if incomplete
// successfully corrects filenames though
// fix this if you ever feel like it, or don't
date_default_timezone_set('utc');
function git_changes(): array {
$files = [];
$dir = getcwd();
try {
chdir(__DIR__ . '/..');
$output = explode("\n", trim(shell_exec('git status --short --porcelain=v1 --untracked-files=all --no-column')));
foreach($output as $line) {
$line = trim($line);
$file = realpath(explode(' ', $line)[1]);
$files[] = $file;
}
} finally {
chdir($dir);
}
return $files;
}
function collect_files(string $directory): array {
$files = [];
$dir = glob(realpath($directory) . DIRECTORY_SEPARATOR . '*');
foreach($dir as $file) {
if(is_dir($file)) {
$files = array_merge($files, collect_files($file));
continue;
}
$files[] = $file;
}
return $files;
}
echo 'Indexing changed files according to git...' . PHP_EOL;
$changed = git_changes();
echo 'Collecting files...' . PHP_EOL;
$sources = collect_files(__DIR__ . '/../src');
$tests = collect_files(__DIR__ . '/../tests');
$files = array_merge($sources, $tests);
$topDir = dirname(__DIR__) . DIRECTORY_SEPARATOR;
$now = gmdate('Y-m-d');
foreach($files as $file) {
echo 'Scanning ' . str_replace($topDir, '', $file) . '...' . PHP_EOL;
try {
$handle = fopen($file, 'rb');
if(!str_starts_with(trim(fgets($handle)), '<?php')) {
echo 'File is not PHP.' . PHP_EOL;
continue;
}
$headerLines = [];
$expectLine = '// ' . basename($file);
$nameLine = trim(fgets($handle));
if($nameLine !== $expectLine) {
echo ' File name is missing or invalid, queuing update...' . PHP_EOL;
$headerLines['name'] = $expectLine;
}
$createdPrefix = '// Created: ';
$createdLine = trim(fgets($handle));
if(strpos($createdLine, $createdPrefix) !== 0) {
echo ' Creation date is missing, queuing update...' . PHP_EOL;
$headerLines['created'] = $createdPrefix . $now;
}
$updatedPrefix = '// Updated: ';
$updatedLine = trim(fgets($handle));
$updatedDate = substr($updatedLine, strlen($updatedPrefix));
if(strpos($updatedLine, $updatedPrefix) !== 0 || (in_array($file, $changed) && $updatedDate !== $now)) {
echo ' Updated date is inaccurate, queuing update...' . PHP_EOL;
$headerLines['updated'] = $updatedPrefix . $now;
}
$blankLine = trim(fgets($handle));
if(!empty($blankLine)) {
echo ' Trailing newline missing, queuing update...' . PHP_EOL;
$headerLines['blank'] = '';
}
if(!empty($headerLines)) {
fclose($handle);
try {
$tmpName = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ndx-uh-' . bin2hex(random_bytes(8)) . '.tmp';
copy($file, $tmpName);
$read = fopen($tmpName, 'rb');
$handle = fopen($file, 'wb');
fwrite($handle, fgets($read));
$insertAfter = [];
if(empty($headerLines['name']))
fwrite($handle, fgets($read));
else {
$line = fgets($read);
if(strpos($line, '// ') !== 0)
$insertAfter[] = $line;
fwrite($handle, $headerLines['name'] . "\n");
}
if(empty($headerLines['created']))
fwrite($handle, fgets($read));
else {
$line = fgets($read);
if(strpos($line, '// Created: ') !== 0)
$insertAfter[] = $line;
fwrite($handle, $headerLines['created'] . "\n");
}
if(empty($headerLines['updated']))
fwrite($handle, fgets($read));
else {
$line = fgets($read);
if(strpos($line, '// Updated: ') !== 0)
$insertAfter[] = $line;
fwrite($handle, $headerLines['updated'] . "\n");
}
if(!isset($headerLines['blank']))
fwrite($handle, fgets($read));
else {
$line = fgets($read);
if(!empty($line)) {
$insertAfter[] = $line;
fwrite($handle, "\n");
}
}
foreach($insertAfter as $line)
fwrite($handle, $line);
while(($line = fgets($read)) !== false)
fwrite($handle, $line);
} finally {
if(is_resource($read))
fclose($read);
if(is_file($tmpName))
unlink($tmpName);
}
}
} finally {
fclose($handle);
}
}