Compare commits

...

4 commits

45 changed files with 625 additions and 1386 deletions

19
TODO.md
View file

@ -1,22 +1,5 @@
# TODO # TODO
## High Prio - Create similarly addressable GD2/Imagick wrappers.
- Create tests for everything testable.
- Create similarly address GD2/Imagick wrappers.
- Figure out the SQL Query builder.
- Might make sense to just have a predicate builder and selector builder.
- Create a URL formatter. - Create a URL formatter.
- Add RSS/Atom feed construction utilities.
## Low Prio
- Get guides working on phpdoc.
- Create phpdoc template.
- Review all constructors and see which one should be marked @internal.

View file

@ -1 +1 @@
0.2410.20209 0.2410.32039

View file

@ -7,7 +7,6 @@ namespace Index\Bencode;
use InvalidArgumentException; use InvalidArgumentException;
use RuntimeException; use RuntimeException;
use Index\Io\{GenericStream,Stream,TempFileStream};
/** /**
* Provides a Bencode serialiser. * Provides a Bencode serialiser.
@ -80,7 +79,7 @@ final class Bencode {
/** /**
* Decodes a bencoded string. * Decodes a bencoded string.
* *
* @param mixed $bencoded The bencoded string being decoded. Can be an Index Stream, readable resource or a string. * @param mixed $bencoded The bencoded string being decoded. Can be a readable resource or a string.
* @param int $depth Maximum nesting depth of the structure being decoded. The value must be greater than 0. * @param int $depth Maximum nesting depth of the structure being decoded. The value must be greater than 0.
* @param bool $associative When true, Bencoded dictionaries will be returned as associative arrays; when false, Bencoded dictionaries will be returned as objects. * @param bool $associative When true, Bencoded dictionaries will be returned as associative arrays; when false, Bencoded dictionaries will be returned as objects.
* @throws InvalidArgumentException If $bencoded string is not set to a valid type. * @throws InvalidArgumentException If $bencoded string is not set to a valid type.
@ -90,18 +89,22 @@ final class Bencode {
*/ */
public static function decode(mixed $bencoded, int $depth = self::DEFAULT_DEPTH, bool $associative = true): mixed { public static function decode(mixed $bencoded, int $depth = self::DEFAULT_DEPTH, bool $associative = true): mixed {
if(is_string($bencoded)) { if(is_string($bencoded)) {
$bencoded = TempFileStream::fromString($bencoded); $handle = fopen('php://memory', 'r+b');
$bencoded->seek(0); if($handle === false)
} elseif(is_resource($bencoded)) throw new RuntimeException('failed to fopen a memory stream');
$bencoded = new GenericStream($bencoded);
elseif(!($bencoded instanceof Stream)) fwrite($handle, $bencoded);
throw new InvalidArgumentException('$bencoded must be a string, an Index Stream or a file resource.'); fseek($handle, 0);
$bencoded = $handle;
$handle = null;
} elseif(!is_resource($bencoded))
throw new InvalidArgumentException('$bencoded must be a string or a stream resource.');
if($depth < 1) if($depth < 1)
throw new InvalidArgumentException('Maximum depth reached, structure is too dense.'); throw new InvalidArgumentException('Maximum depth reached, structure is too dense.');
$char = $bencoded->readChar(); $char = fgetc($bencoded);
if($char === null) if($char === false)
throw new RuntimeException('Unexpected end of stream in $bencoded.'); throw new RuntimeException('Unexpected end of stream in $bencoded.');
switch($char) { switch($char) {
@ -109,8 +112,8 @@ final class Bencode {
$number = ''; $number = '';
for(;;) { for(;;) {
$char = $bencoded->readChar(); $char = fgetc($bencoded);
if($char === null) if($char === false)
throw new RuntimeException('Unexpected end of stream while parsing integer.'); throw new RuntimeException('Unexpected end of stream while parsing integer.');
if($char === 'e') if($char === 'e')
break; break;
@ -131,12 +134,12 @@ final class Bencode {
$list = []; $list = [];
for(;;) { for(;;) {
$char = $bencoded->readChar(); $char = fgetc($bencoded);
if($char === null) if($char === false)
throw new RuntimeException('Unexpected end of stream while parsing list.'); throw new RuntimeException('Unexpected end of stream while parsing list.');
if($char === 'e') if($char === 'e')
break; break;
$bencoded->seek(-1, Stream::CURRENT); fseek($bencoded, -1, SEEK_CUR);
$list[] = self::decode($bencoded, $depth - 1, $associative); $list[] = self::decode($bencoded, $depth - 1, $associative);
} }
@ -146,14 +149,14 @@ final class Bencode {
$dict = []; $dict = [];
for(;;) { for(;;) {
$char = $bencoded->readChar(); $char = fgetc($bencoded);
if($char === null) if($char === false)
throw new RuntimeException('Unexpected end of stream while parsing dictionary'); throw new RuntimeException('Unexpected end of stream while parsing dictionary');
if($char === 'e') if($char === 'e')
break; break;
if(!ctype_digit($char)) if(!ctype_digit($char))
throw new RuntimeException('Unexpected dictionary key type, expected a string.'); throw new RuntimeException('Unexpected dictionary key type, expected a string.');
$bencoded->seek(-1, Stream::CURRENT); fseek($bencoded, -1, SEEK_CUR);
$dict[self::decode($bencoded, $depth - 1, $associative)] = self::decode($bencoded, $depth - 1, $associative); $dict[self::decode($bencoded, $depth - 1, $associative)] = self::decode($bencoded, $depth - 1, $associative);
} }
@ -169,8 +172,8 @@ final class Bencode {
$length = $char; $length = $char;
for(;;) { for(;;) {
$char = $bencoded->readChar(); $char = fgetc($bencoded);
if($char === null) if($char === false)
throw new RuntimeException('Unexpected end of character while parsing string length.'); throw new RuntimeException('Unexpected end of character while parsing string length.');
if($char === ':') if($char === ':')
break; break;
@ -182,7 +185,13 @@ final class Bencode {
$length .= $char; $length .= $char;
} }
return $bencoded->read((int)$length); $length = (int)$length;
if($length < 0)
throw new RuntimeException('Length is Bencoded stream is less than 0.');
if($length < 1)
return '';
return fread($bencoded, $length);
} }
} }
} }

View file

@ -1,17 +1,17 @@
<?php <?php
// BencodedContent.php // BencodeHttpContent.php
// Created: 2022-02-10 // Created: 2022-02-10
// Updated: 2024-10-02 // Updated: 2024-10-02
namespace Index\Http\Content; namespace Index\Bencode;
use Index\Bencode\{Bencode,BencodeSerializable}; use RuntimeException;
use Index\Io\{Stream,FileStream}; use Index\Http\HttpContent;
/** /**
* Represents Bencoded body content for a HTTP message. * Represents Bencoded body content for a HTTP message.
*/ */
class BencodedContent implements BencodeSerializable, HttpContent { class BencodeHttpContent implements BencodeSerializable, HttpContent {
/** /**
* @param mixed $content Content to be bencoded. * @param mixed $content Content to be bencoded.
*/ */
@ -49,28 +49,36 @@ class BencodedContent implements BencodeSerializable, HttpContent {
* Creates an instance from encoded content. * Creates an instance from encoded content.
* *
* @param mixed $encoded Bencoded content. * @param mixed $encoded Bencoded content.
* @return BencodedContent Instance representing the provided content. * @return BencodeHttpContent Instance representing the provided content.
*/ */
public static function fromEncoded(mixed $encoded): BencodedContent { public static function fromEncoded(mixed $encoded): BencodeHttpContent {
return new BencodedContent(Bencode::decode($encoded)); return new BencodeHttpContent(Bencode::decode($encoded));
} }
/** /**
* Creates an instance from an encoded file. * Creates an instance from an encoded file.
* *
* @param string $path Path to the bencoded file. * @param string $path Path to the bencoded file.
* @return BencodedContent Instance representing the provided path. * @return BencodeHttpContent Instance representing the provided path.
*/ */
public static function fromFile(string $path): BencodedContent { public static function fromFile(string $path): BencodeHttpContent {
return self::fromEncoded(FileStream::openRead($path)); $handle = fopen($path, 'rb');
if(!is_resource($handle))
throw new RuntimeException('$path could not be opened');
try {
return self::fromEncoded($handle);
} finally {
fclose($handle);
}
} }
/** /**
* Creates an instance from the raw request body. * Creates an instance from the raw request body.
* *
* @return BencodedContent Instance representing the request body. * @return BencodeHttpContent Instance representing the request body.
*/ */
public static function fromRequest(): BencodedContent { public static function fromRequest(): BencodeHttpContent {
return self::fromFile('php://input'); return self::fromFile('php://input');
} }
} }

View file

@ -1,18 +1,16 @@
<?php <?php
// BencodeContentHandler.php // BencodeHttpContentHandler.php
// Created: 2024-03-28 // Created: 2024-03-28
// Updated: 2024-10-02 // Updated: 2024-10-02
namespace Index\Http\ContentHandling; namespace Index\Bencode;
use Index\Bencode\BencodeSerializable; use Index\Http\{HttpContentHandler,HttpResponseBuilder};
use Index\Http\HttpResponseBuilder;
use Index\Http\Content\BencodedContent;
/** /**
* Represents a Bencode content handler for building HTTP response messages. * Represents a Bencode content handler for building HTTP response messages.
*/ */
class BencodeContentHandler implements ContentHandler { class BencodeHttpContentHandler implements HttpContentHandler {
public function match(mixed $content): bool { public function match(mixed $content): bool {
return $content instanceof BencodeSerializable; return $content instanceof BencodeSerializable;
} }
@ -21,6 +19,6 @@ class BencodeContentHandler implements ContentHandler {
if(!$response->hasContentType()) if(!$response->hasContentType())
$response->setTypePlain(); $response->setTypePlain();
$response->setContent(new BencodedContent($content)); $response->setContent(new BencodeHttpContent($content));
} }
} }

View file

@ -303,7 +303,9 @@ abstract class Colour implements Stringable {
return self::$none; return self::$none;
} }
/** @internal */ /**
* Initialises the Colour class.
*/
public static function init(): void { public static function init(): void {
self::$none = new ColourNull; self::$none = new ColourNull;
} }

View file

@ -6,7 +6,6 @@
namespace Index\Data; namespace Index\Data;
use Index\Closeable; use Index\Closeable;
use Index\Io\Stream;
/** /**
* Represents a database result set. * Represents a database result set.
@ -99,14 +98,6 @@ interface DbResult extends Closeable {
*/ */
function getBooleanOrNull(int|string $index): ?bool; function getBooleanOrNull(int|string $index): ?bool;
/**
* Gets the value from the target index as a Stream.
*
* @param int|string $index Target index.
* @return ?Stream A Stream if data is available, null if not.
*/
function getStream(int|string $index): ?Stream;
/** /**
* Creates an iterator for this result. * Creates an iterator for this result.
* *

View file

@ -154,11 +154,9 @@ final class DbTools {
return DbType::FLOAT; return DbType::FLOAT;
if(is_int($value)) if(is_int($value))
return DbType::INTEGER; return DbType::INTEGER;
// ┌ should probably also check for Stringable, length should also be taken into consideration if(is_resource($value))
// ↓ though maybe with that it's better to assume that when an object is passed it'll always be Massive return DbType::BLOB;
if(is_string($value)) return DbType::STRING;
return DbType::STRING;
return DbType::BLOB;
} }
/** /**

View file

@ -18,7 +18,10 @@ class MariaDbBackend implements DbBackend {
} }
/** /**
* @internal * Formats a MySQL integer version as a string.
*
* @param int $version MySQL version number.
* @return string
*/ */
public static function intToVersion(int $version): string { public static function intToVersion(int $version): string {
$sub = $version % 100; $sub = $version % 100;

View file

@ -9,7 +9,6 @@ use mysqli_result;
use mysqli_stmt; use mysqli_stmt;
use InvalidArgumentException; use InvalidArgumentException;
use Index\Data\{DbResult,DbResultTrait}; use Index\Data\{DbResult,DbResultTrait};
use Index\Io\{Stream,TempFileStream};
/** /**
* Represents a MariaDB/MySQL database result. * Represents a MariaDB/MySQL database result.
@ -68,17 +67,6 @@ abstract class MariaDbResult implements DbResult {
return $this->currentRow[$index] ?? null; return $this->currentRow[$index] ?? null;
} }
public function getStream(int|string $index): ?Stream {
if($this->isNull($index))
return null;
$value = $this->getValue($index);
if(!is_scalar($value))
return null;
return new TempFileStream((string)$value);
}
abstract function close(): void; abstract function close(): void;
public function __destruct() { public function __destruct() {

View file

@ -10,8 +10,6 @@ use RuntimeException;
/** /**
* Implementation of MariaDbResult for libmysql. * Implementation of MariaDbResult for libmysql.
*
* @internal
*/ */
class MariaDbResultLib extends MariaDbResult { class MariaDbResultLib extends MariaDbResult {
/** @var mixed[] */ /** @var mixed[] */

View file

@ -11,8 +11,6 @@ use RuntimeException;
/** /**
* Implementation of MariaDbResult for mysqlnd. * Implementation of MariaDbResult for mysqlnd.
*
* @internal
*/ */
class MariaDbResultNative extends MariaDbResult { class MariaDbResultNative extends MariaDbResult {
public function __construct(mysqli_stmt|mysqli_result $result) { public function __construct(mysqli_stmt|mysqli_result $result) {

View file

@ -8,8 +8,8 @@ namespace Index\Data\MariaDb;
use mysqli_stmt; use mysqli_stmt;
use InvalidArgumentException; use InvalidArgumentException;
use RuntimeException; use RuntimeException;
use Stringable;
use Index\Data\{DbType,DbStatement}; use Index\Data\{DbType,DbStatement};
use Index\Io\Stream;
/** /**
* Represents a MariaDB/MySQL prepared statement. * Represents a MariaDB/MySQL prepared statement.
@ -35,8 +35,6 @@ class MariaDbStatement implements DbStatement {
/** /**
* Determines which MariaDbResult implementation should be used. * Determines which MariaDbResult implementation should be used.
*
* @internal
*/ */
public static function construct(): void { public static function construct(): void {
if(self::$constructed !== false) if(self::$constructed !== false)
@ -132,13 +130,10 @@ class MariaDbStatement implements DbStatement {
if($type === DbType::NULL) if($type === DbType::NULL)
$value = null; $value = null;
elseif($type === DbType::BLOB) { elseif($type === DbType::BLOB) {
if($value instanceof Stream) { if(is_resource($value)) {
while(($data = $value->read(8192)) !== null)
$this->statement->send_long_data($key, $data);
} elseif(is_resource($value)) {
while($data = fread($value, 8192)) while($data = fread($value, 8192))
$this->statement->send_long_data($key, $data); $this->statement->send_long_data($key, $data);
} elseif(is_scalar($value)) } elseif(is_scalar($value) || $value instanceof Stringable)
$this->statement->send_long_data($key, (string)$value); $this->statement->send_long_data($key, (string)$value);
$value = null; $value = null;

View file

@ -6,7 +6,6 @@
namespace Index\Data\NullDb; namespace Index\Data\NullDb;
use Index\Data\{DbResult,DbResultIterator}; use Index\Data\{DbResult,DbResultIterator};
use Index\Io\Stream;
/** /**
* Represents a dummy database result. * Represents a dummy database result.
@ -56,10 +55,6 @@ class NullDbResult implements DbResult {
return null; return null;
} }
public function getStream(int|string $index): ?Stream {
return null;
}
public function getIterator(callable $construct): DbResultIterator { public function getIterator(callable $construct): DbResultIterator {
return new DbResultIterator($this, $construct); return new DbResultIterator($this, $construct);
} }

View file

@ -8,7 +8,6 @@ namespace Index\Data\Sqlite;
use RuntimeException; use RuntimeException;
use SQLite3; use SQLite3;
use Index\Data\{DbConnection,DbTransactions,DbStatement,DbResult}; use Index\Data\{DbConnection,DbTransactions,DbStatement,DbResult};
use Index\Io\{GenericStream,Stream};
/** /**
* Represents a client for an SQLite database. * Represents a client for an SQLite database.
@ -458,22 +457,20 @@ class SqliteConnection implements DbConnection, DbTransactions {
} }
/** /**
* Opens a BLOB field as a Stream. * Opens a BLOB field as a resource.
*
* To be used instead of getStream on SqliteResult.
* *
* @param string $table Name of the source table. * @param string $table Name of the source table.
* @param string $column Name of the source column. * @param string $column Name of the source column.
* @param int $rowId ID of the source row. * @param int $rowId ID of the source row.
* @throws RuntimeException If opening the BLOB failed. * @throws RuntimeException If opening the BLOB failed.
* @return Stream BLOB field as a Stream. * @return resource BLOB field as a file resource.
*/ */
public function getBlobStream(string $table, string $column, int $rowId): Stream { public function getBlobStream(string $table, string $column, int $rowId): mixed {
$handle = $this->connection->openBlob($table, $column, $rowId); $handle = $this->connection->openBlob($table, $column, $rowId);
if(!is_resource($handle)) if(!is_resource($handle))
throw new RuntimeException((string)$this->getLastErrorString(), $this->getLastErrorCode()); throw new RuntimeException((string)$this->getLastErrorString(), $this->getLastErrorCode());
return new GenericStream($handle); return $handle;
} }
public function getLastInsertId(): int|string { public function getLastInsertId(): int|string {

View file

@ -8,7 +8,6 @@ namespace Index\Data\Sqlite;
use SQLite3Result; use SQLite3Result;
use InvalidArgumentException; use InvalidArgumentException;
use Index\Data\{DbResult,DbResultTrait}; use Index\Data\{DbResult,DbResultTrait};
use Index\Io\{Stream,TempFileStream};
/** /**
* Represents an SQLite result set. * Represents an SQLite result set.
@ -54,20 +53,5 @@ class SqliteResult implements DbResult {
return $this->currentRow[$index] ?? null; return $this->currentRow[$index] ?? null;
} }
/**
* Gets the value from the target index as a Stream.
* If you're aware that you're using SQLite it may make more sense to use SqliteConnection::getBlobStream instead.
*/
public function getStream(int|string $index): ?Stream {
if($this->isNull($index))
return null;
$value = $this->getValue($index);
if(!is_scalar($value))
return null;
return new TempFileStream((string)$value);
}
public function close(): void {} public function close(): void {}
} }

View file

@ -10,7 +10,6 @@ use SQLite3Stmt;
use InvalidArgumentException; use InvalidArgumentException;
use RuntimeException; use RuntimeException;
use Index\Data\{DbTools,DbType,DbStatement,DbResult}; use Index\Data\{DbTools,DbType,DbStatement,DbResult};
use Index\Io\Stream;
/** /**
* Represents a prepared SQLite SQL statement. * Represents a prepared SQLite SQL statement.

View file

@ -1,54 +0,0 @@
<?php
// StreamContent.php
// Created: 2022-02-10
// Updated: 2024-10-02
namespace Index\Http\Content;
use Index\Io\{Stream,FileStream};
/**
* Represents Stream body content for a HTTP message.
*/
class StreamContent implements HttpContent {
/**
* @param Stream $stream Stream that represents this message body.
*/
public function __construct(
private Stream $stream
) {}
/**
* Retrieves the underlying stream.
*
* @return Stream Underlying stream.
*/
public function getStream(): Stream {
return $this->stream;
}
public function __toString(): string {
return (string)$this->stream;
}
/**
* Creates an instance from a file.
*
* @param string $path Path to the file.
* @return StreamContent Instance representing the provided path.
*/
public static function fromFile(string $path): StreamContent {
return new StreamContent(
FileStream::openRead($path)
);
}
/**
* Creates an instance from the raw request body.
*
* @return StreamContent Instance representing the request body.
*/
public static function fromRequest(): StreamContent {
return self::fromFile('php://input');
}
}

View file

@ -1,27 +0,0 @@
<?php
// StreamContentHandler.php
// Created: 2024-03-28
// Updated: 2024-10-02
namespace Index\Http\ContentHandling;
use Index\Http\HttpResponseBuilder;
use Index\Http\Content\StreamContent;
use Index\Io\Stream;
/**
* Represents a Stream content handler for building HTTP response messages.
*/
class StreamContentHandler implements ContentHandler {
public function match(mixed $content): bool {
return $content instanceof Stream;
}
/** @param Stream $content */
public function handle(HttpResponseBuilder $response, mixed $content): void {
if(!$response->hasContentType())
$response->setTypeStream();
$response->setContent(new StreamContent($content));
}
}

View file

@ -1,17 +1,16 @@
<?php <?php
// FormContent.php // FormHttpContent.php
// Created: 2022-02-10 // Created: 2022-02-10
// Updated: 2024-10-02 // Updated: 2024-10-02
namespace Index\Http\Content; namespace Index\Http;
use RuntimeException; use RuntimeException;
use Index\Http\HttpUploadedFile;
/** /**
* Represents form body content for a HTTP message. * Represents form body content for a HTTP message.
*/ */
class FormContent implements HttpContent { class FormHttpContent implements HttpContent {
/** /**
* @param array<string, mixed> $postFields Form fields. * @param array<string, mixed> $postFields Form fields.
* @param array<string, HttpUploadedFile|array<string, mixed>> $uploadedFiles Uploaded files. * @param array<string, HttpUploadedFile|array<string, mixed>> $uploadedFiles Uploaded files.
@ -95,10 +94,10 @@ class FormContent implements HttpContent {
* *
* @param array<string, mixed> $post Form fields. * @param array<string, mixed> $post Form fields.
* @param array<string, mixed> $files Uploaded files. * @param array<string, mixed> $files Uploaded files.
* @return FormContent Instance representing the request body. * @return FormHttpContent Instance representing the request body.
*/ */
public static function fromRaw(array $post, array $files): FormContent { public static function fromRaw(array $post, array $files): FormHttpContent {
return new FormContent( return new FormHttpContent(
$post, $post,
HttpUploadedFile::createFromPhpFiles($files) HttpUploadedFile::createFromPhpFiles($files)
); );
@ -107,9 +106,9 @@ class FormContent implements HttpContent {
/** /**
* Creates an instance from the $_POST and $_FILES superglobals. * Creates an instance from the $_POST and $_FILES superglobals.
* *
* @return FormContent Instance representing the request body. * @return FormHttpContent Instance representing the request body.
*/ */
public static function fromRequest(): FormContent { public static function fromRequest(): FormHttpContent {
/** @var array<string, mixed> $postVars */ /** @var array<string, mixed> $postVars */
$postVars = $_POST; $postVars = $_POST;

View file

@ -1,16 +1,14 @@
<?php <?php
// HtmlErrorHandler.php // HtmlHttpErrorHandler.php
// Created: 2024-03-28 // Created: 2024-03-28
// Updated: 2024-10-02 // Updated: 2024-10-02
namespace Index\Http\ErrorHandling; namespace Index\Http;
use Index\Http\{HttpResponseBuilder,HttpRequest};
/** /**
* Represents a basic HTML error message handler for building HTTP response messages. * Represents a basic HTML error message handler for building HTTP response messages.
*/ */
class HtmlErrorHandler implements ErrorHandler { class HtmlHttpErrorHandler implements HttpErrorHandler {
private const TEMPLATE = <<<HTML private const TEMPLATE = <<<HTML
<!doctype html> <!doctype html>
<html> <html>

View file

@ -3,7 +3,7 @@
// Created: 2022-02-08 // Created: 2022-02-08
// Updated: 2024-10-02 // Updated: 2024-10-02
namespace Index\Http\Content; namespace Index\Http;
use Stringable; use Stringable;

View file

@ -1,16 +1,14 @@
<?php <?php
// ContentHandler.php // HttpContentHandler.php
// Created: 2024-03-28 // Created: 2024-03-28
// Updated: 2024-10-02 // Updated: 2024-10-02
namespace Index\Http\ContentHandling; namespace Index\Http;
use Index\Http\HttpResponseBuilder;
/** /**
* Represents a content handler for building HTTP response messages. * Represents a content handler for building HTTP response messages.
*/ */
interface ContentHandler { interface HttpContentHandler {
/** /**
* Determines whether this handler is suitable for the body content. * Determines whether this handler is suitable for the body content.
* *

View file

@ -1,16 +1,14 @@
<?php <?php
// ErrorHandler.php // HttpErrorHandler.php
// Created: 2024-03-28 // Created: 2024-03-28
// Updated: 2024-10-02 // Updated: 2024-10-02
namespace Index\Http\ErrorHandling; namespace Index\Http;
use Index\Http\{HttpResponseBuilder,HttpRequest};
/** /**
* Represents an error message handler for building HTTP response messages. * Represents an error message handler for building HTTP response messages.
*/ */
interface ErrorHandler { interface HttpErrorHandler {
/** /**
* Applies an error message template to the provided HTTP response builder. * Applies an error message template to the provided HTTP response builder.
* *

View file

@ -6,8 +6,8 @@
namespace Index\Http; namespace Index\Http;
use RuntimeException; use RuntimeException;
use Index\Io\Stream; use Index\Bencode\BencodeHttpContent;
use Index\Http\Content\{BencodedContent,FormContent,HttpContent,JsonContent,StreamContent,StringContent}; use Index\Json\JsonHttpContent;
/** /**
* Represents a base HTTP message. * Represents a base HTTP message.
@ -112,47 +112,38 @@ abstract class HttpMessage {
} }
/** /**
* Checks if the body content is JsonContent. * Checks if the body content is JsonHttpContent.
* *
* @return bool true if it is JsonContent. * @return bool true if it is JsonHttpContent.
*/ */
public function isJsonContent(): bool { public function isJsonContent(): bool {
return $this->content instanceof JsonContent; return $this->content instanceof JsonHttpContent;
} }
/** /**
* Checks if the body content is FormContent. * Checks if the body content is FormHttpContent.
* *
* @return bool true if it is FormContent. * @return bool true if it is FormHttpContent.
*/ */
public function isFormContent(): bool { public function isFormContent(): bool {
return $this->content instanceof FormContent; return $this->content instanceof FormHttpContent;
} }
/** /**
* Checks if the body content is StreamContent. * Checks if the body content is StringHttpContent.
* *
* @return bool true if it is StreamContent. * @return bool true if it is StringHttpContent.
*/
public function isStreamContent(): bool {
return $this->content instanceof StreamContent;
}
/**
* Checks if the body content is StringContent.
*
* @return bool true if it is StringContent.
*/ */
public function isStringContent(): bool { public function isStringContent(): bool {
return $this->content instanceof StringContent; return $this->content instanceof StringHttpContent;
} }
/** /**
* Checks if the body content is BencodedContent. * Checks if the body content is BencodeHttpContent.
* *
* @return bool true if it is BencodedContent. * @return bool true if it is BencodeHttpContent.
*/ */
public function isBencodedContent(): bool { public function isBencodeContent(): bool {
return $this->content instanceof BencodedContent; return $this->content instanceof BencodeHttpContent;
} }
} }

View file

@ -5,8 +5,9 @@
namespace Index\Http; namespace Index\Http;
use Index\Io\Stream; use InvalidArgumentException;
use Index\Http\Content\{HttpContent,StreamContent,StringContent}; use RuntimeException;
use Stringable;
/** /**
* Represents a base HTTP message builder. * Represents a base HTTP message builder.
@ -119,13 +120,28 @@ class HttpMessageBuilder {
/** /**
* Sets HTTP message body contents. * Sets HTTP message body contents.
* *
* @param HttpContent|Stream|string|null $content Body contents * @param HttpContent|Stringable|string|int|float|resource|null $content Body contents
*/ */
public function setContent(HttpContent|Stream|string|null $content): void { public function setContent(mixed $content): void {
if($content instanceof Stream) if($content instanceof HttpContent || $content === null) {
$content = new StreamContent($content); $this->content = $content;
elseif(is_string($content)) return;
$content = new StringContent($content); }
$this->content = $content;
if(is_scalar($content) || $content instanceof Stringable) {
$this->content = new StringHttpContent((string)$content);
return;
}
if(is_resource($content)) {
$content = stream_get_contents($content);
if($content === false)
throw new RuntimeException('was unable to read the stream resource in $content');
$this->content = new StringHttpContent($content);
return;
}
throw new InvalidArgumentException('$content not a supported type');
} }
} }

View file

@ -8,7 +8,7 @@ namespace Index\Http;
use InvalidArgumentException; use InvalidArgumentException;
use RuntimeException; use RuntimeException;
use Index\MediaType; use Index\MediaType;
use Index\Http\Content\{HttpContent,JsonContent,StreamContent,FormContent}; use Index\Json\JsonHttpContent;
/** /**
* Represents a HTTP request message. * Represents a HTTP request message.
@ -182,12 +182,12 @@ class HttpRequest extends HttpMessage {
if($contentType !== null if($contentType !== null
&& ($contentType->equals('application/json') || $contentType->equals('application/x-json'))) && ($contentType->equals('application/json') || $contentType->equals('application/x-json')))
$build->setContent(JsonContent::fromRequest()); $build->setContent(JsonHttpContent::fromRequest());
elseif($contentType !== null elseif($contentType !== null
&& ($contentType->equals('application/x-www-form-urlencoded') || $contentType->equals('multipart/form-data'))) && ($contentType->equals('application/x-www-form-urlencoded') || $contentType->equals('multipart/form-data')))
$build->setContent(FormContent::fromRequest()); $build->setContent(FormHttpContent::fromRequest());
elseif($contentLength > 0) elseif($contentLength > 0)
$build->setContent(StreamContent::fromRequest()); $build->setContent(StringHttpContent::fromRequest());
return $build->toRequest(); return $build->toRequest();
} }

View file

@ -5,8 +5,6 @@
namespace Index\Http; namespace Index\Http;
use Index\Http\Content\HttpContent;
/** /**
* Represents a HTTP response message. * Represents a HTTP response message.
*/ */

View file

@ -7,16 +7,13 @@ namespace Index\Http;
use InvalidArgumentException; use InvalidArgumentException;
use RuntimeException; use RuntimeException;
use Index\{MediaType,Closeable}; use Index\MediaType;
use Index\Io\{Stream,FileStream};
/** /**
* Represents an uploaded file in a multipart/form-data request. * Represents an uploaded file in a multipart/form-data request.
*/ */
class HttpUploadedFile implements Closeable { class HttpUploadedFile {
private bool $hasMoved = false; private bool $hasMoved = false;
private ?Stream $stream = null;
private MediaType $suggestedMediaType; private MediaType $suggestedMediaType;
/** /**
@ -101,25 +98,6 @@ class HttpUploadedFile implements Closeable {
return $this->hasMoved; return $this->hasMoved;
} }
/**
* Gets a read-only stream to the uploaded file.
*
* @throws RuntimeException If the file cannot be opened.
* @return Stream Read-only stream of the upload file.
*/
public function getStream(): Stream {
if($this->stream === null) {
if($this->errorCode !== UPLOAD_ERR_OK)
throw new RuntimeException('Can\'t open stream because of an upload error.');
if($this->hasMoved)
throw new RuntimeException('Can\'t open stream because file has already been moved.');
$this->stream = FileStream::openRead($this->localFileName);
}
return $this->stream;
}
/** /**
* Moves the uploaded file to its final destination. * Moves the uploaded file to its final destination.
* *
@ -138,9 +116,6 @@ class HttpUploadedFile implements Closeable {
if(empty($path)) if(empty($path))
throw new InvalidArgumentException('$path is not a valid path.'); throw new InvalidArgumentException('$path is not a valid path.');
if($this->stream !== null)
$this->stream->close();
$this->hasMoved = PHP_SAPI === 'CLI' $this->hasMoved = PHP_SAPI === 'CLI'
? rename($this->localFileName, $path) ? rename($this->localFileName, $path)
: move_uploaded_file($this->localFileName, $path); : move_uploaded_file($this->localFileName, $path);
@ -151,15 +126,6 @@ class HttpUploadedFile implements Closeable {
$this->localFileName = $path; $this->localFileName = $path;
} }
public function close(): void {
if($this->stream !== null)
$this->stream->close();
}
public function __destruct() {
$this->close();
}
/** /**
* Creates a HttpUploadedFile instance from an entry in the $_FILES superglobal. * Creates a HttpUploadedFile instance from an entry in the $_FILES superglobal.
* *

View file

@ -1,16 +1,14 @@
<?php <?php
// PlainErrorHandler.php // PlainHttpErrorHandler.php
// Created: 2024-03-28 // Created: 2024-03-28
// Updated: 2024-10-02 // Updated: 2024-10-02
namespace Index\Http\ErrorHandling; namespace Index\Http;
use Index\Http\{HttpResponseBuilder,HttpRequest};
/** /**
* Represents a plain text error message handler for building HTTP response messages. * Represents a plain text error message handler for building HTTP response messages.
*/ */
class PlainErrorHandler implements ErrorHandler { class PlainHttpErrorHandler implements HttpErrorHandler {
public function handle(HttpResponseBuilder $response, HttpRequest $request, int $code, string $message): void { public function handle(HttpResponseBuilder $response, HttpRequest $request, int $code, string $message): void {
$response->setTypePlain(); $response->setTypePlain();
$response->setContent(sprintf('HTTP %03d %s', $code, $message)); $response->setContent(sprintf('HTTP %03d %s', $code, $message));

View file

@ -7,10 +7,12 @@ namespace Index\Http\Routing;
use stdClass; use stdClass;
use InvalidArgumentException; use InvalidArgumentException;
use Index\Http\{HttpResponse,HttpResponseBuilder,HttpRequest}; use Index\Bencode\BencodeHttpContentHandler;
use Index\Http\Content\StringContent; use Index\Http\{
use Index\Http\ContentHandling\{BencodeContentHandler,ContentHandler,JsonContentHandler,StreamContentHandler}; HtmlHttpErrorHandler,HttpContentHandler,HttpErrorHandler,HttpResponse,
use Index\Http\ErrorHandling\{HtmlErrorHandler,ErrorHandler,PlainErrorHandler}; HttpResponseBuilder,HttpRequest,PlainHttpErrorHandler,StringHttpContent
};
use Index\Json\JsonHttpContentHandler;
class HttpRouter implements Router { class HttpRouter implements Router {
use RouterTrait; use RouterTrait;
@ -24,19 +26,19 @@ class HttpRouter implements Router {
/** @var array<string, array<string, callable>> */ /** @var array<string, array<string, callable>> */
private array $dynamicRoutes = []; private array $dynamicRoutes = [];
/** @var ContentHandler[] */ /** @var HttpContentHandler[] */
private array $contentHandlers = []; private array $contentHandlers = [];
private ErrorHandler $errorHandler; private HttpErrorHandler $errorHandler;
/** /**
* @param string $charSet Default character set to specify when none is present. * @param string $charSet Default character set to specify when none is present.
* @param ErrorHandler|string $errorHandler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation. * @param HttpErrorHandler|string $errorHandler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation.
* @param bool $registerDefaultContentHandlers true to register default content handlers for JSON, Bencode, etc. * @param bool $registerDefaultContentHandlers true to register default content handlers for JSON, Bencode, etc.
*/ */
public function __construct( public function __construct(
private string $charSet = '', private string $charSet = '',
ErrorHandler|string $errorHandler = 'html', HttpErrorHandler|string $errorHandler = 'html',
bool $registerDefaultContentHandlers = true, bool $registerDefaultContentHandlers = true,
) { ) {
$this->setErrorHandler($errorHandler); $this->setErrorHandler($errorHandler);
@ -65,22 +67,22 @@ class HttpRouter implements Router {
/** /**
* Retrieves the error handler instance. * Retrieves the error handler instance.
* *
* @return ErrorHandler The error handler. * @return HttpErrorHandler The error handler.
*/ */
public function getErrorHandler(): ErrorHandler { public function getErrorHandler(): HttpErrorHandler {
return $this->errorHandler; return $this->errorHandler;
} }
/** /**
* Changes the active error handler. * Changes the active error handler.
* *
* @param ErrorHandler|string $handler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation. * @param HttpErrorHandler|string $handler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation.
*/ */
public function setErrorHandler(ErrorHandler|string $handler): void { public function setErrorHandler(HttpErrorHandler|string $handler): void {
if($handler instanceof ErrorHandler) if($handler instanceof HttpErrorHandler)
$this->errorHandler = $handler; $this->errorHandler = $handler;
elseif($handler === 'html') elseif($handler === 'html')
$this->setHTMLErrorHandler(); $this->setHtmlErrorHandler();
else // plain else // plain
$this->setPlainErrorHandler(); $this->setPlainErrorHandler();
} }
@ -88,23 +90,23 @@ class HttpRouter implements Router {
/** /**
* Set the error handler to the basic HTML one. * Set the error handler to the basic HTML one.
*/ */
public function setHTMLErrorHandler(): void { public function setHtmlErrorHandler(): void {
$this->errorHandler = new HtmlErrorHandler; $this->errorHandler = new HtmlHttpErrorHandler;
} }
/** /**
* Set the error handler to the plain text one. * Set the error handler to the plain text one.
*/ */
public function setPlainErrorHandler(): void { public function setPlainErrorHandler(): void {
$this->errorHandler = new PlainErrorHandler; $this->errorHandler = new PlainHttpErrorHandler;
} }
/** /**
* Register a message body content handler. * Register a message body content handler.
* *
* @param ContentHandler $contentHandler Content handler to register. * @param HttpContentHandler $contentHandler Content handler to register.
*/ */
public function registerContentHandler(ContentHandler $contentHandler): void { public function registerContentHandler(HttpContentHandler $contentHandler): void {
if(!in_array($contentHandler, $this->contentHandlers)) if(!in_array($contentHandler, $this->contentHandlers))
$this->contentHandlers[] = $contentHandler; $this->contentHandlers[] = $contentHandler;
} }
@ -113,9 +115,8 @@ class HttpRouter implements Router {
* Register the default content handlers. * Register the default content handlers.
*/ */
public function registerDefaultContentHandlers(): void { public function registerDefaultContentHandlers(): void {
$this->registerContentHandler(new StreamContentHandler); $this->registerContentHandler(new JsonHttpContentHandler);
$this->registerContentHandler(new JsonContentHandler); $this->registerContentHandler(new BencodeHttpContentHandler);
$this->registerContentHandler(new BencodeContentHandler);
} }
/** /**
@ -281,7 +282,7 @@ class HttpRouter implements Router {
if(!$response->hasStatusCode() && $result >= 100 && $result < 600) if(!$response->hasStatusCode() && $result >= 100 && $result < 600)
$this->writeErrorPage($response, $request, $result); $this->writeErrorPage($response, $request, $result);
else else
$response->setContent(new StringContent((string)$result)); $response->setContent(new StringHttpContent((string)$result));
} elseif(!$response->hasContent()) { } elseif(!$response->hasContent()) {
foreach($this->contentHandlers as $contentHandler) foreach($this->contentHandlers as $contentHandler)
if($contentHandler->match($result)) { if($contentHandler->match($result)) {
@ -291,7 +292,7 @@ class HttpRouter implements Router {
if(!$response->hasContent() && is_scalar($result)) { if(!$response->hasContent() && is_scalar($result)) {
$result = (string)$result; $result = (string)$result;
$response->setContent(new StringContent($result)); $response->setContent(new StringHttpContent($result));
if(!$response->hasContentType()) { if(!$response->hasContentType()) {
if(strtolower(substr($result, 0, 14)) === '<!doctype html') if(strtolower(substr($result, 0, 14)) === '<!doctype html')

View file

@ -1,16 +1,16 @@
<?php <?php
// StringContent.php // StringHttpContent.php
// Created: 2022-02-10 // Created: 2022-02-10
// Updated: 2024-10-02 // Updated: 2024-10-02
namespace Index\Http\Content; namespace Index\Http;
use Stringable; use Stringable;
/** /**
* Represents string body content for a HTTP message. * Represents string body content for a HTTP message.
*/ */
class StringContent implements HttpContent { class StringHttpContent implements HttpContent {
/** /**
* @param string $string String that represents this message body. * @param string $string String that represents this message body.
*/ */
@ -35,32 +35,32 @@ class StringContent implements HttpContent {
* Creates an instance an existing object. * Creates an instance an existing object.
* *
* @param Stringable|string $string Object to cast to a string. * @param Stringable|string $string Object to cast to a string.
* @return StringContent Instance representing the provided object. * @return StringHttpContent Instance representing the provided object.
*/ */
public static function fromObject(Stringable|string $string): StringContent { public static function fromObject(Stringable|string $string): StringHttpContent {
return new StringContent((string)$string); return new StringHttpContent((string)$string);
} }
/** /**
* Creates an instance from a file. * Creates an instance from a file.
* *
* @param string $path Path to the file. * @param string $path Path to the file.
* @return StringContent Instance representing the provided path. * @return StringHttpContent Instance representing the provided path.
*/ */
public static function fromFile(string $path): StringContent { public static function fromFile(string $path): StringHttpContent {
$string = file_get_contents($path); $string = file_get_contents($path);
if($string === false) if($string === false)
$string = ''; $string = '';
return new StringContent($string); return new StringHttpContent($string);
} }
/** /**
* Creates an instance from the raw request body. * Creates an instance from the raw request body.
* *
* @return StringContent Instance representing the request body. * @return StringHttpContent Instance representing the request body.
*/ */
public static function fromRequest(): StringContent { public static function fromRequest(): StringHttpContent {
return self::fromFile('php://input'); return self::fromFile('php://input');
} }
} }

View file

@ -1,256 +0,0 @@
<?php
// FileStream.php
// Created: 2021-04-30
// Updated: 2024-10-02
namespace Index\Io;
use ErrorException;
use RuntimeException;
/**
* Represents a Stream representing a file.
*/
class FileStream extends GenericStream {
/**
* Open file for reading, throw if not exists.
*
* @var string
*/
public const OPEN_READ = 'rb';
/**
* Open file for reading and writing, throw if not exist.
*
* @var string
*/
public const OPEN_READ_WRITE = 'r+b';
/**
* Create file for writing, truncate if exist.
*
* @var string
*/
public const NEW_WRITE = 'wb';
/**
* Create file for reading and writing, truncate if exist.
*
* @var string
*/
public const NEW_READ_WRITE = 'w+b';
/**
* Open file for appending, create if not exist.
*
* @var string
*/
public const APPEND_WRITE = 'ab';
/**
* Open file for reading and appending, create if not exist.
*
* @var string
*/
public const APPEND_READ_WRITE = 'a+b';
/**
* Create file for writing, throw if exist.
*
* @var string
*/
public const CREATE_WRITE = 'xb';
/**
* Create file for reading and writing, throw if exist.
*
* @var string
*/
public const CREATE_READ_WRITE = 'x+b';
/**
* Opens or creates a file for writing.
*
* @var string
*/
public const OPEN_OR_CREATE_WRITE = 'cb';
/**
* Opens or creates a file for reading and writing.
*
* @var string
*/
public const OPEN_OR_CREATE_READ_WRITE = 'c+b';
/**
* Don't lock file.
*
* @var int
*/
public const LOCK_NONE = 0;
/**
* Lock for reading.
*
* @var int
*/
public const LOCK_READ = LOCK_SH;
/**
* Lock for writing.
*
* @var int
*/
public const LOCK_WRITE = LOCK_EX;
/**
* Don't block during lock attempt.
*
* @var int
*/
public const LOCK_NON_BLOCKING = LOCK_NB;
/**
* @param string $path Path to the file.
* @param string $mode Stream mode to use to open the file.
* @param int $lock Locking method to use.
* @throws RuntimeException If the file could not be opened.
*/
public function __construct(
string $path,
string $mode,
private int $lock
) {
try {
$stream = fopen($path, $mode);
} catch(ErrorException $ex) {
throw new RuntimeException('An error occurred while trying to open a file.', $ex->getCode(), $ex);
}
if($stream === false)
throw new RuntimeException('An unhandled error occurred while trying to open a file.');
parent::__construct($stream);
if($this->lock & self::LOCK_WRITE)
flock($this->stream, LOCK_EX);
elseif($this->lock & self::LOCK_READ)
flock($this->stream, LOCK_SH);
}
public function close(): void {
if($this->lock !== self::LOCK_NONE)
flock($this->stream, LOCK_UN);
parent::close();
}
/**
* Open file for reading, throw if not exists.
*
* @param string $path Path to the file.
* @param int $lock Locking method to use.
* @return FileStream Stream to file.
*/
public static function openRead(string $path, int $lock = self::LOCK_NONE): FileStream {
return new FileStream($path, self::OPEN_READ, $lock);
}
/**
* Open file for reading and writing, throw if not exist.
*
* @param string $path Path to the file.
* @param int $lock Locking method to use.
* @return FileStream Stream to file.
*/
public static function openReadWrite(string $path, int $lock = self::LOCK_NONE): FileStream {
return new FileStream($path, self::OPEN_READ_WRITE, $lock);
}
/**
* Create file for writing, truncate if exist.
*
* @param string $path Path to the file.
* @param int $lock Locking method to use.
* @return FileStream Stream to file.
*/
public static function newWrite(string $path, int $lock = self::LOCK_NONE): FileStream {
return new FileStream($path, self::NEW_WRITE, $lock);
}
/**
* Create file for reading and writing, truncate if exist.
*
* @param string $path Path to the file.
* @param int $lock Locking method to use.
* @return FileStream Stream to file.
*/
public static function newReadWrite(string $path, int $lock = self::LOCK_NONE): FileStream {
return new FileStream($path, self::NEW_READ_WRITE, $lock);
}
/**
* Open file for appending, create if not exist.
*
* @param string $path Path to the file.
* @param int $lock Locking method to use.
* @return FileStream Stream to file.
*/
public static function appendWrite(string $path, int $lock = self::LOCK_NONE): FileStream {
return new FileStream($path, self::APPEND_WRITE, $lock);
}
/**
* Open file for reading and appending, create if not exist.
*
* @param string $path Path to the file.
* @param int $lock Locking method to use.
* @return FileStream Stream to file.
*/
public static function appendReadWrite(string $path, int $lock = self::LOCK_NONE): FileStream {
return new FileStream($path, self::APPEND_READ_WRITE, $lock);
}
/**
* Create file for writing, throw if exist.
*
* @param string $path Path to the file.
* @param int $lock Locking method to use.
* @return FileStream Stream to file.
*/
public static function createWrite(string $path, int $lock = self::LOCK_NONE): FileStream {
return new FileStream($path, self::CREATE_WRITE, $lock);
}
/**
* Create file for reading and writing, throw if exist.
*
* @param string $path Path to the file.
* @param int $lock Locking method to use.
* @return FileStream Stream to file.
*/
public static function createReadWrite(string $path, int $lock = self::LOCK_NONE): FileStream {
return new FileStream($path, self::CREATE_READ_WRITE, $lock);
}
/**
* Opens or creates a file for writing.
*
* @param string $path Path to the file.
* @param int $lock Locking method to use.
* @return FileStream Stream to file.
*/
public static function openOrCreateWrite(string $path, int $lock = self::LOCK_NONE): FileStream {
return new FileStream($path, self::OPEN_OR_CREATE_WRITE, $lock);
}
/**
* Opens or creates a file for reading and writing.
*
* @param string $path Path to the file.
* @param int $lock Locking method to use.
* @return FileStream Stream to file.
*/
public static function openOrCreateReadWrite(string $path, int $lock = self::LOCK_NONE): FileStream {
return new FileStream($path, self::OPEN_OR_CREATE_READ_WRITE, $lock);
}
}

View file

@ -1,195 +0,0 @@
<?php
// GenericStream.php
// Created: 2021-04-30
// Updated: 2024-10-02
namespace Index\Io;
use InvalidArgumentException;
use RuntimeException;
/**
* Represents any stream that can be handled using the C-like f* functions.
*/
class GenericStream extends Stream {
/**
* Readable file modes.
*
* @var string[]
*/
public const READABLE = [
'r', 'rb', 'r+', 'r+b', 'w+', 'w+b',
'a+', 'a+b', 'x+', 'x+b', 'c+', 'c+b',
];
/**
* Writeable file modes.
*
* @var string[]
*/
public const WRITEABLE = [
'r+', 'r+b', 'w', 'wb', 'w+', 'w+b',
'a', 'ab', 'a+', 'a+b', 'x', 'xb',
'x+', 'x+b', 'c', 'cb', 'c+', 'c+b',
];
// can't seem to turn this one into a constructor property thing?
/** @var resource */
protected $stream;
private bool $canRead;
private bool $canWrite;
private bool $canSeek;
/**
* @param resource $stream Resource that this stream represents.
*/
public function __construct($stream) {
if(!is_resource($stream) || get_resource_type($stream) !== 'stream')
throw new InvalidArgumentException('$stream is not a valid stream resource.');
$this->stream = $stream;
$metaData = $this->metaData();
$this->canRead = in_array($metaData['mode'], self::READABLE);
$this->canWrite = in_array($metaData['mode'], self::WRITEABLE);
$this->canSeek = is_bool($metaData['seekable']) ? $metaData['seekable'] : false;
}
/**
* Returns the underlying resource handle.
*
* @return resource Underlying resource handle.
*/
public function getResource() {
return $this->stream;
}
/** @return array<string|int, mixed> */
private function stat(): array {
$stat = fstat($this->stream);
if($stat === false)
throw new RuntimeException('fstat returned false');
return $stat;
}
public function getPosition(): int {
$tell = ftell($this->stream);
if($tell === false)
return -1;
return $tell;
}
public function getLength(): int {
if(!$this->canSeek())
return -1;
$size = $this->stat()['size'];
if(!is_int($size))
return -1;
return $size;
}
public function setLength(int $length): void {
if($length < 0)
throw new InvalidArgumentException('$length must be a positive integer');
ftruncate($this->stream, $length);
}
/** @return array<string, mixed> */
private function metaData(): array {
return stream_get_meta_data($this->stream);
}
public function canRead(): bool {
return $this->canRead;
}
public function canWrite(): bool {
return $this->canWrite;
}
public function canSeek(): bool {
return $this->canSeek;
}
public function hasTimedOut(): bool {
$timedOut = $this->metaData()['timed_out'];
if(!is_bool($timedOut))
return false;
return $timedOut;
}
public function isBlocking(): bool {
$blocked = $this->metaData()['blocked'];
if(!is_bool($blocked))
return false;
return $blocked;
}
public function isEnded(): bool {
return feof($this->stream);
}
public function readChar(): ?string {
$char = fgetc($this->stream);
if($char === false)
return null;
return $char;
}
public function readLine(): ?string {
return ($line = fgets($this->stream)) === false
? null : $line;
}
public function read(int $length): ?string {
if($length < 1)
throw new InvalidArgumentException('$length must be greater than 0');
$buffer = fread($this->stream, $length);
if($buffer === false)
return null;
return $buffer;
}
public function seek(int $offset, int $origin = Stream::START): bool {
return fseek($this->stream, $offset, $origin) === 0;
}
public function write(string $buffer, int $length = -1): void {
if($length >= 0)
fwrite($this->stream, $buffer, $length);
else
fwrite($this->stream, $buffer);
}
public function writeChar(string $char): void {
fwrite($this->stream, $char, 1);
}
public function flush(): void {
fflush($this->stream);
}
public function close(): void {
try {
fclose($this->stream);
} catch(\Error $ex) {}
}
#[\Override]
public function copyTo(Stream $other): void {
if($other instanceof GenericStream) {
stream_copy_to_stream($this->stream, $other->stream);
} else parent::copyTo($other);
}
public function __toString(): string {
$contents = stream_get_contents($this->stream);
if($contents === false)
return '';
return $contents;
}
}

View file

@ -1,33 +0,0 @@
<?php
// MemoryStream.php
// Created: 2021-05-02
// Updated: 2024-10-02
namespace Index\Io;
use RuntimeException;
/**
* Represents an in-memory stream.
*/
class MemoryStream extends GenericStream {
public function __construct() {
$stream = fopen('php://memory', 'r+b');
if($stream === false)
throw new RuntimeException('failed to fopen a memory stream');
parent::__construct($stream);
}
/**
* Creates a memory stream from a string/binary data.
*
* @param string $string String to create the stream from.
* @return MemoryStream Stream representing the string.
*/
public static function fromString(string $string): MemoryStream {
$stream = new MemoryStream;
$stream->write($string);
return $stream;
}
}

View file

@ -1,149 +0,0 @@
<?php
// NetworkStream.php
// Created: 2021-04-30
// Updated: 2024-10-02
namespace Index\Io;
use ErrorException;
use RuntimeException;
use Index\Net\{IpAddress,EndPoint};
/**
* Represents a network socket stream.
*/
class NetworkStream extends GenericStream {
/**
* @param string $hostname Hostname to connect to.
* @param int $port Port to connect at.
* @param float|null $timeout Amount of seconds until timeout, null for php.ini default.
* @throws RuntimeException If the socket failed to open.
*/
public function __construct(string $hostname, int $port, float|null $timeout) {
try {
$stream = fsockopen($hostname, $port, $errcode, $errmsg, $timeout);
} catch(ErrorException $ex) {
throw new RuntimeException('An error occurred while trying to connect.', $ex->getCode(), $ex);
}
if($stream === false)
throw new RuntimeException('An unhandled error occurred while trying to connect.');
parent::__construct($stream);
}
/**
* Sets whether the network stream should have blocking reads and writes.
*
* @param bool $blocking true to block, false to async.
*/
public function setBlocking(bool $blocking): void {
stream_set_blocking($this->stream, $blocking);
}
/**
* Opens a network socket stream to an endpoint represented by an Index EndPoint instance.
*
* @param EndPoint $endPoint Host to connect to.
* @param float|null $timeout Amount of seconds until timeout, null for php.ini default.
*/
public static function openEndPoint(EndPoint $endPoint, float|null $timeout = null): NetworkStream {
return new NetworkStream((string)$endPoint, -1, $timeout);
}
/**
* Opens an SSL network socket stream to an endpoint represented by an Index EndPoint instance.
*
* @param EndPoint $endPoint Host to connect to.
* @param float|null $timeout Amount of seconds until timeout, null for php.ini default.
*/
public static function openEndPointSSL(EndPoint $endPoint, float|null $timeout = null): NetworkStream {
return new NetworkStream('ssl://' . ((string)$endPoint), -1, $timeout);
}
/**
* Opens a TLS network socket stream to an endpoint represented by an Index EndPoint instance.
*
* @param EndPoint $endPoint Host to connect to.
* @param float|null $timeout Amount of seconds until timeout, null for php.ini default.
*/
public static function openEndPointTLS(EndPoint $endPoint, float|null $timeout = null): NetworkStream {
return new NetworkStream('tls://' . ((string)$endPoint), -1, $timeout);
}
/**
* Opens a network socket stream to an endpoint represented by an hostname and port.
*
* @param string $hostname Hostname to connect to.
* @param int $port Port to connect at.
* @param float|null $timeout Amount of seconds until timeout, null for php.ini default.
*/
public static function openHost(string $hostname, int $port, float|null $timeout = null): NetworkStream {
return new NetworkStream($hostname, $port, $timeout);
}
/**
* Opens an SSL network socket stream to an endpoint represented by an hostname and port.
*
* @param string $hostname Hostname to connect to.
* @param int $port Port to connect at.
* @param float|null $timeout Amount of seconds until timeout, null for php.ini default.
*/
public static function openHostSSL(string $hostname, int $port, float|null $timeout = null): NetworkStream {
return new NetworkStream('ssl://' . ((string)$hostname), $port, $timeout);
}
/**
* Opens a TLS network socket stream to an endpoint represented by an hostname and port.
*
* @param string $hostname Hostname to connect to.
* @param int $port Port to connect at.
* @param float|null $timeout Amount of seconds until timeout, null for php.ini default.
*/
public static function openHostTLS(string $hostname, int $port, float|null $timeout = null): NetworkStream {
return new NetworkStream('tls://' . ((string)$hostname), $port, $timeout);
}
/**
* Opens a network socket stream to an endpoint represented by an Index IpAddress instance and port.
*
* @param IpAddress $address Address to connect to.
* @param int $port Port to connect at.
* @param float|null $timeout Amount of seconds until timeout, null for php.ini default.
*/
public static function openAddress(IpAddress $address, int $port, float|null $timeout = null): NetworkStream {
return new NetworkStream($address->getAddress(), $port, $timeout);
}
/**
* Opens an SSL network socket stream to an endpoint represented by an Index IpAddress instance and port.
*
* @param IpAddress $address Address to connect to.
* @param int $port Port to connect at.
* @param float|null $timeout Amount of seconds until timeout, null for php.ini default.
*/
public static function openAddressSSL(IpAddress $address, int $port, float|null $timeout = null): NetworkStream {
return new NetworkStream('ssl://' . $address->getAddress(), $port, $timeout);
}
/**
* Opens a TLS network socket stream to an endpoint represented by an Index IpAddress instance and port.
*
* @param IpAddress $address Address to connect to.
* @param int $port Port to connect at.
* @param float|null $timeout Amount of seconds until timeout, null for php.ini default.
*/
public static function openAddressTLS(IpAddress $address, int $port, float|null $timeout = null): NetworkStream {
return new NetworkStream('tls://' . $address->getAddress(), $port, $timeout);
}
/**
* Opens a network socket stream to an endpoint represented by a UNIX socket path.
*
* @param string $path Path to connect to.
* @param float|null $timeout Amount of seconds until timeout, null for php.ini default.
*/
public static function openUnix(string $path, float|null $timeout = null): NetworkStream {
return new NetworkStream('unix://' . $path, -1, $timeout);
}
}

View file

@ -1,116 +0,0 @@
<?php
// ProcessStream.php
// Created: 2023-01-25
// Updated: 2024-10-02
namespace Index\Io;
use InvalidArgumentException;
use RuntimeException;
/**
* Represents a stream to a running sub-process.
*/
class ProcessStream extends Stream {
/** @var resource */
private $handle;
private bool $canRead;
private bool $canWrite;
/**
* @param string $command Command to run for this stream.
* @param string $mode File mode to use.
* @throws RuntimeException If we were unable to spawn the process.
*/
public function __construct(string $command, string $mode) {
$handle = popen($command, $mode);
if($handle === false)
throw new RuntimeException('Failed to create process.');
$this->handle = $handle;
$this->canRead = strpos($mode, 'r') !== false;
$this->canWrite = strpos($mode, 'w') !== false;
if(!is_resource($this->handle))
throw new RuntimeException('Failed to create process.');
}
public function getPosition(): int {
return -1;
}
public function getLength(): int {
return -1;
}
public function setLength(int $length): void {}
public function canRead(): bool {
return $this->canRead;
}
public function canWrite(): bool {
return $this->canWrite;
}
public function canSeek(): bool {
return false;
}
public function hasTimedOut(): bool {
return false;
}
public function isBlocking(): bool {
return true;
}
public function isEnded(): bool {
return feof($this->handle);
}
public function readChar(): ?string {
$char = fgetc($this->handle);
if($char === false)
return null;
return $char;
}
public function readLine(): ?string {
return ($line = fgets($this->handle)) === false
? null : $line;
}
public function read(int $length): ?string {
if($length < 1)
throw new InvalidArgumentException('$length must be greater than 0');
$buffer = fread($this->handle, $length);
if($buffer === false)
return null;
return $buffer;
}
public function seek(int $offset, int $origin = self::START): bool {
throw new RuntimeException('Cannot seek ProcessStream.');
}
public function write(string $buffer, int $length = -1): void {
if($length >= 0)
fwrite($this->handle, $buffer, $length);
else
fwrite($this->handle, $buffer);
}
public function writeChar(string $char): void {
fwrite($this->handle, $char, 1);
}
public function flush(): void {}
public function close(): void {
if(is_resource($this->handle))
pclose($this->handle);
}
}

View file

@ -1,219 +0,0 @@
<?php
// Stream.php
// Created: 2021-04-30
// Updated: 2024-10-02
namespace Index\Io;
use RuntimeException;
use Stringable;
use Index\Closeable;
/**
* Represents a generic data stream.
*/
abstract class Stream implements Stringable, Closeable {
/**
* Place the cursor relative to the start of the file.
*
* @var int
*/
public const START = SEEK_SET;
/**
* Place the cursor relative to the current position of the cursor.
*
* @var int
*/
public const CURRENT = SEEK_CUR;
/**
* Place the cursor relative to the end of the file.
*
* @var int
*/
public const END = SEEK_END;
/**
* Retrieves the current cursor position.
*
* @return int Cursor position.
*/
abstract public function getPosition(): int;
/**
* Retrieves the current length of the stream.
*
* @return int Stream length.
*/
abstract public function getLength(): int;
/**
* Sets the length of the stream.
*
* @param int $length Desired stream length.
*/
abstract public function setLength(int $length): void;
/**
* Whether this stream is readable.
*
* @return bool true if the stream is readable.
*/
abstract public function canRead(): bool;
/**
* Whether this stream is writeable.
*
* @return bool true if the stream is writeable.
*/
abstract public function canWrite(): bool;
/**
* Whether this stream is seekable.
*
* @return bool true if the stream is seekable.
*/
abstract public function canSeek(): bool;
/**
* Whether this stream has timed out.
*
* @return bool true if the stream has timed out.
*/
abstract public function hasTimedOut(): bool;
/**
* Whether this stream has blocking operations.
*
* @return bool true if the stream has blocking operations.
*/
abstract public function isBlocking(): bool;
/**
* Whether the cursor is at the end of the file.
*
* @return bool true if the cursor is at the end of the file.
*/
abstract public function isEnded(): bool;
/**
* Read a single character from the stream.
*
* @return ?string One character or null if we're at the end of the stream.
*/
abstract public function readChar(): ?string;
/**
* Read a single byte from the stream.
*
* @return int One byte or -1 if we're at the end of the stream.
*/
public function readByte(): int {
$char = $this->readChar();
if($char === null)
return -1;
return ord($char);
}
/**
* Read a line of text from the stream.
*
* @return ?string One line of text or null if we're at the end of the stream.
*/
abstract public function readLine(): ?string;
/**
* Read an amount of data from the stream.
*
* @param int $length Maximum amount of data to read.
* @return ?string At most a $length sized string or null if we're at the end of the stream.
*/
abstract public function read(int $length): ?string;
/**
* Move the cursor to a different place in the stream.
*
* @param int $offset Offset to apply to the cursor position.
* @param int $origin Point from which to apply the offset.
* @throws RuntimeException If the stream is not seekable.
* @return bool true if the cursor offset was applied successfully.
*/
abstract public function seek(int $offset, int $origin = self::START): bool;
/**
* Write an amount of data to the stream.
*
* @param string $buffer Buffer to write from.
* @param int $length Amount of data to write from the buffer, or -1 to write the entire buffer.
*/
abstract public function write(string $buffer, int $length = -1): void;
/**
* Writes a line of text to the stream.
*
* @param string $line Line of text to write.
*/
public function writeLine(string $line): void {
$this->write($line);
$this->write(PHP_EOL);
}
/**
* Writes a single character to the stream.
*
* @param string $char Character to write to the stream.
*/
abstract public function writeChar(string $char): void;
/**
* Writes a single byte to the stream.
*
* @param int $byte Byte to write to the stream.
*/
public function writeByte(int $byte): void {
$this->writeChar(chr($byte & 0xFF));
}
/**
* Copy the entire contents of this stream to another.
*
* Will seek to the beginning of the stream if the stream is seekable.
*
* @param Stream $other Target stream.
*/
public function copyTo(Stream $other): void {
if($this->canSeek())
$this->seek(0);
while(!$this->isEnded()) {
$buffer = $this->read(8192);
if($buffer === null)
break;
$other->write($buffer);
}
}
/**
* Force write of all buffered output to the stream.
*/
abstract public function flush(): void;
abstract public function close(): void;
public function __toString(): string {
if($this->canSeek())
$this->seek(0);
$string = '';
while(!$this->isEnded())
$string .= $this->read(8192);
return $string;
}
public function __destruct() {
$this->close();
}
}

View file

@ -1,47 +0,0 @@
<?php
// TempFileStream.php
// Created: 2021-05-02
// Updated: 2024-10-02
namespace Index\Io;
use RuntimeException;
/**
* Represents a temporary file stream. Will remain in memory if the size is below a given threshold.
*/
class TempFileStream extends GenericStream {
/**
* Default maximum size the stream can be to remain in memory and not be written to disk.
*
* @var int
*/
public const MAX_MEMORY = 2 * 1024 * 1024;
/**
* @param ?string $body Contents of the stream.
* @param int $maxMemory Maximum filesize until the file is moved out of memory and to disk.
*/
public function __construct(?string $body = null, int $maxMemory = self::MAX_MEMORY) {
$stream = fopen("php://temp/maxmemory:{$maxMemory}", 'r+b');
if($stream === false)
throw new RuntimeException('failed to fopen a temporary file stream');
parent::__construct($stream);
if($body !== null) {
$this->write($body);
$this->flush();
}
}
/**
* Creates a TempFileStream from a string.
*
* @param string $string Data to write to the temporary file.
* @return TempFileStream Stream representing the given text.
*/
public static function fromString(string $string): TempFileStream {
return new TempFileStream($string);
}
}

View file

@ -1,17 +1,18 @@
<?php <?php
// JsonContent.php // JsonHttpContent.php
// Created: 2022-02-10 // Created: 2022-02-10
// Updated: 2024-10-02 // Updated: 2024-10-02
namespace Index\Http\Content; namespace Index\Json;
use JsonSerializable; use JsonSerializable;
use Index\Io\{Stream,FileStream}; use RuntimeException;
use Index\Http\HttpContent;
/** /**
* Represents JSON body content for a HTTP message. * Represents JSON body content for a HTTP message.
*/ */
class JsonContent implements HttpContent, JsonSerializable { class JsonHttpContent implements HttpContent, JsonSerializable {
/** /**
* @param mixed $content Content to be JSON encoded. * @param mixed $content Content to be JSON encoded.
*/ */
@ -53,28 +54,32 @@ class JsonContent implements HttpContent, JsonSerializable {
* Creates an instance from encoded content. * Creates an instance from encoded content.
* *
* @param string $encoded JSON encoded content. * @param string $encoded JSON encoded content.
* @return JsonContent Instance representing the provided content. * @return JsonHttpContent Instance representing the provided content.
*/ */
public static function fromEncoded(string $encoded): JsonContent { public static function fromEncoded(string $encoded): JsonHttpContent {
return new JsonContent(json_decode($encoded)); return new JsonHttpContent(json_decode($encoded));
} }
/** /**
* Creates an instance from an encoded file. * Creates an instance from an encoded file.
* *
* @param string $path Path to the JSON encoded file. * @param string $path Path to the JSON encoded file.
* @return JsonContent Instance representing the provided path. * @return JsonHttpContent Instance representing the provided path.
*/ */
public static function fromFile(string $path): JsonContent { public static function fromFile(string $path): JsonHttpContent {
return self::fromEncoded(FileStream::openRead($path)); $contents = file_get_contents($path);
if($contents === false)
throw new RuntimeException('was unable to read file at $path');
return self::fromEncoded($contents);
} }
/** /**
* Creates an instance from the raw request body. * Creates an instance from the raw request body.
* *
* @return JsonContent Instance representing the request body. * @return JsonHttpContent Instance representing the request body.
*/ */
public static function fromRequest(): JsonContent { public static function fromRequest(): JsonHttpContent {
return self::fromFile('php://input'); return self::fromFile('php://input');
} }
} }

View file

@ -1,19 +1,18 @@
<?php <?php
// JsonContentHandler.php // JsonHttpContentHandler.php
// Created: 2024-03-28 // Created: 2024-03-28
// Updated: 2024-10-02 // Updated: 2024-10-02
namespace Index\Http\ContentHandling; namespace Index\Json;
use stdClass; use stdClass;
use JsonSerializable; use JsonSerializable;
use Index\Http\HttpResponseBuilder; use Index\Http\{HttpContentHandler,HttpResponseBuilder};
use Index\Http\Content\JsonContent;
/** /**
* Represents a JSON content handler for building HTTP response messages. * Represents a JSON content handler for building HTTP response messages.
*/ */
class JsonContentHandler implements ContentHandler { class JsonHttpContentHandler implements HttpContentHandler {
public function match(mixed $content): bool { public function match(mixed $content): bool {
return is_array($content) || $content instanceof JsonSerializable || $content instanceof stdClass; return is_array($content) || $content instanceof JsonSerializable || $content instanceof stdClass;
} }
@ -22,6 +21,6 @@ class JsonContentHandler implements ContentHandler {
if(!$response->hasContentType()) if(!$response->hasContentType())
$response->setTypeJson(); $response->setTypeJson();
$response->setContent(new JsonContent($content)); $response->setContent(new JsonHttpContent($content));
} }
} }

View file

@ -0,0 +1,147 @@
<?php
// FeedBuilder.php
// Created: 2024-10-02
// Updated: 2024-10-03
namespace Index\Syndication;
use DateTimeInterface;
use DOMDocument;
use Stringable;
use RuntimeException;
use Index\{XDateTime,XString};
/**
* Provides an RSS feed builder with Atom extensions.
*/
class FeedBuilder implements Stringable {
private string $title = 'Feed';
private string $description = '';
private string $updatedAt = '';
private string $contentUrl = '';
private string $feedUrl = '';
/** @var FeedItemBuilder[] */
private array $items = [];
/**
* Sets the title of the feed.
*
* @param string $title Feed title.
*/
public function setTitle(string $title): void {
$this->title = $title;
}
/**
* Sets the description of the feed, may contain HTML.
*
* @param string $description Feed description.
*/
public function setDescription(string $description): void {
$this->description = $description;
}
/**
* Sets the last updated time for this feed.
*
* @param DateTimeInterface|int $updatedAt Timestamp at which the feed was last updated.
*/
public function setUpdatedAt(DateTimeInterface|int $updatedAt): void {
$this->updatedAt = XDateTime::toRfc822String($updatedAt);
}
/**
* Sets the URL to the content this feed represents.
*
* @param string $url Content URL.
*/
public function setContentUrl(string $url): void {
$this->contentUrl = $url;
}
/**
* Sets the URL to this feed's XML file.
*
* @param string $url Feed XML URL.
*/
public function setFeedUrl(string $url): void {
$this->feedUrl = $url;
}
/**
* Creates a feed item using a builder.
*
* @param callable(FeedItemBuilder): void $handler Item builder closure.
*/
public function createEntry(callable $handler): void {
$builder = new FeedItemBuilder;
$handler($builder);
$this->items[] = $builder;
}
/**
* Creates a DOMDocument from this builder.
*
* @return DOMDocument XML document representing this feed.
*/
public function toXmlDocument(): DOMDocument {
$document = new DOMDocument('1.0', 'UTF-8');
$document->preserveWhiteSpace = false;
$document->formatOutput = true;
$rss = $document->createElement('rss');
$rss->setAttribute('version', '2.0');
$rss->setAttribute('xmlns:atom', 'http://www.w3.org/2005/Atom');
$document->appendChild($rss);
$channel = $document->createElement('channel');
$elements = [
'title' => $this->title,
'pubDate' => $this->updatedAt,
'link' => $this->contentUrl,
];
foreach($elements as $elemName => $elemValue)
if(!XString::nullOrWhitespace($elemValue))
$channel->appendChild($document->createElement($elemName, $elemValue));
if(!XString::nullOrWhitespace($this->description)) {
$description = $document->createElement('description');
$description->appendChild($document->createCDATASection($this->description));
$channel->appendChild($description);
}
if(!XString::nullOrWhitespace($this->feedUrl)) {
$element = $document->createElement('atom:link');
$element->setAttribute('href', $this->feedUrl);
$element->setAttribute('ref', 'self');
$element->setAttribute('type', 'application/rss+xml');
$channel->appendChild($element);
}
foreach($this->items as $item)
$channel->appendChild($item->createElement($document));
$rss->appendChild($channel);
return $document;
}
/**
* Converts the XML Document to a printable string.
*
* @return string Printable XML document.
*/
public function toXmlString(): string {
$string = $this->toXmlDocument()->saveXML();
if($string === false)
$string = '';
return $string;
}
public function __toString(): string {
return $this->toXmlString();
}
}

View file

@ -0,0 +1,137 @@
<?php
// FeedItemBuilder.php
// Created: 2024-10-02
// Updated: 2024-10-03
namespace Index\Syndication;
use DateTimeInterface;
use DOMDocument;
use DOMElement;
use Index\{XDateTime,XString};
/**
* Provides an RSS feed item builder with Atom extensions.
*/
class FeedItemBuilder {
private string $title = 'Feed Item';
private string $description = '';
private string $createdAt = '';
private string $updatedAt = '';
private string $contentUrl = '';
private string $commentsUrl = '';
private string $authorName = '';
private string $authorUrl = '';
/**
* Sets the title of this feed item.
*
* @param string $title Item title.
*/
public function setTitle(string $title): void {
$this->title = $title;
}
/**
* Sets the description of the feed item, may contain HTML.
*
* @param string $description Item description.
*/
public function setDescription(string $description): void {
$this->description = $description;
}
/**
* Sets the creation time for this feed item.
*
* @param DateTimeInterface|int $createdAt Timestamp at which the feed item was created.
*/
public function setCreatedAt(DateTimeInterface|int $createdAt): void {
$this->createdAt = XDateTime::toRfc822String($createdAt);
}
/**
* Sets the last updated time for this feed item.
*
* @param DateTimeInterface|int $updatedAt Timestamp at which the feed item was last updated.
*/
public function setUpdatedAt(DateTimeInterface|int $updatedAt): void {
$this->updatedAt = XDateTime::toIso8601String($updatedAt);
}
/**
* Sets the URL to the content this feed item represents.
*
* @param string $url Content URL.
*/
public function setContentUrl(string $url): void {
$this->contentUrl = $url;
}
/**
* Sets the URL to where comments can be made on the content this feed item represents.
*
* @param string $url Comments URL.
*/
public function setCommentsUrl(string $url): void {
$this->commentsUrl = $url;
}
/**
* Sets the name of the author of this feed item.
*
* @param string $name Name of the author.
*/
public function setAuthorName(string $name): void {
$this->authorName = $name;
}
/**
* Sets the URL for the author of this feed item. Has no effect if no author name is provided.
*
* @param string $url URL for the author.
*/
public function setAuthorUrl(string $url): void {
$this->authorUrl = $url;
}
/**
* Creates an XML document based on this item builder.
*
* @param DOMDocument $document Document to which this element is to be appended.
* @return DOMElement Element created from this builder.
*/
public function createElement(DOMDocument $document): DOMElement {
$item = $document->createElement('item');
$elements = [
'title' => $this->title,
'pubDate' => $this->createdAt,
'atom:updated' => $this->updatedAt,
'link' => $this->contentUrl,
'guid' => $this->contentUrl,
'comments' => $this->commentsUrl,
];
foreach($elements as $elemName => $elemValue)
if(!XString::nullOrWhitespace($elemValue))
$item->appendChild($document->createElement($elemName, $elemValue));
if(!XString::nullOrWhitespace($this->description)) {
$description = $document->createElement('description');
$description->appendChild($document->createCDATASection($this->description));
$item->appendChild($description);
}
if(!XString::nullOrWhitespace($this->authorName)) {
$author = $document->createElement('atom:author');
$author->appendChild($document->createElement('atom:name', $this->authorName));
if(!XString::nullOrWhitespace($this->authorUrl))
$author->appendChild($document->createElement('atom:uri', $this->authorUrl));
$item->appendChild($author);
}
return $item;
}
}

View file

@ -11,21 +11,27 @@ use Index\Data\{DbTools,DbType};
use Index\Data\MariaDb\MariaDbBackend; use Index\Data\MariaDb\MariaDbBackend;
use Index\Data\NullDb\NullDbConnection; use Index\Data\NullDb\NullDbConnection;
use Index\Data\Sqlite\SqliteBackend; use Index\Data\Sqlite\SqliteBackend;
use Index\Io\MemoryStream;
#[CoversClass(DbTools::class)] #[CoversClass(DbTools::class)]
#[CoversClass(DbType::class)] #[CoversClass(DbType::class)]
#[CoversClass(NullDbConnection::class)] #[CoversClass(NullDbConnection::class)]
#[CoversClass(MariaDbBackend::class)] #[CoversClass(MariaDbBackend::class)]
#[CoversClass(SqliteBackend::class)] #[CoversClass(SqliteBackend::class)]
#[UsesClass(MemoryStream::class)]
final class DbToolsTest extends TestCase { final class DbToolsTest extends TestCase {
public function testDetectType(): void { public function testDetectType(): void {
$this->assertEquals(DbType::NULL, DbTools::detectType(null)); $this->assertEquals(DbType::NULL, DbTools::detectType(null));
$this->assertEquals(DbType::INTEGER, DbTools::detectType(12345)); $this->assertEquals(DbType::INTEGER, DbTools::detectType(12345));
$this->assertEquals(DbType::FLOAT, DbTools::detectType(123.45)); $this->assertEquals(DbType::FLOAT, DbTools::detectType(123.45));
$this->assertEquals(DbType::STRING, DbTools::detectType('This is a string.')); $this->assertEquals(DbType::STRING, DbTools::detectType('This is a string.'));
$this->assertEquals(DbType::BLOB, DbTools::detectType(MemoryStream::fromString('This is a string inside a memory stream.')));
$blob = fopen('php://memory', 'r+b');
if($blob === false)
throw new RuntimeException('failed to fopen a memory stream');
fwrite($blob, 'This is a string inside a memory stream.');
fseek($blob, 0);
$this->assertEquals(DbType::BLOB, DbTools::detectType($blob));
} }
public function testDSN(): void { public function testDSN(): void {

132
tests/SyndicationTest.php Normal file
View file

@ -0,0 +1,132 @@
<?php
// SyndicationTest.php
// Created: 2024-10-03
// Updated: 2024-10-03
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use Index\Syndication\{FeedBuilder,FeedItemBuilder};
#[CoversClass(FeedBuilder::class)]
#[CoversClass(FeedItemBuilder::class)]
final class SyndicationTest extends TestCase {
private const BLANK_FEED = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Feed</title>
</channel>
</rss>
XML;
private const NO_ITEMS_FEED = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Index RSS</title>
<pubDate>Thu, 03 Oct 24 20:06:25 +0000</pubDate>
<link>https://example.railgun.sh/articles</link>
<description><![CDATA[<b>This should accept HTML!</b>]]></description>
<atom:link href="https://example.railgun.sh/feed.xml" ref="self" type="application/rss+xml"/>
</channel>
</rss>
XML;
private const ITEMS_FEED = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Index RSS</title>
<pubDate>Thu, 03 Oct 24 20:06:25 +0000</pubDate>
<link>https://example.railgun.sh/articles</link>
<description><![CDATA[<b>This should accept HTML!</b>]]></description>
<atom:link href="https://example.railgun.sh/feed.xml" ref="self" type="application/rss+xml"/>
<item>
<title>Article 1</title>
<pubDate>Sat, 28 Sep 24 01:13:05 +0000</pubDate>
<link>https://example.railgun.sh/articles/1</link>
<guid>https://example.railgun.sh/articles/1</guid>
<comments>https://example.railgun.sh/articles/1#comments</comments>
<description><![CDATA[<p>This is an article!</p>
<p>It has <b>HTML</b> and <em>multiple lines</em> and all that <u>awesome</u> stuff!!!!</p>
<h2>Meaning it can also have sections</h2>
<p>Technology is so cool...</p>]]></description>
<atom:author>
<atom:name>flashwave</atom:name>
<atom:uri>https://example.railgun.sh/author/1</atom:uri>
</atom:author>
</item>
<item>
<title>Article 2</title>
<pubDate>Sun, 29 Sep 24 04:59:45 +0000</pubDate>
<link>https://example.railgun.sh/articles/2</link>
<guid>https://example.railgun.sh/articles/2</guid>
<comments>https://example.railgun.sh/articles/2#comments</comments>
<description><![CDATA[<p>hidden author spooky</p>]]></description>
</item>
<item>
<title>Article 3 without a description</title>
<pubDate>Mon, 30 Sep 24 08:46:25 +0000</pubDate>
<link>https://example.railgun.sh/articles/3</link>
<guid>https://example.railgun.sh/articles/3</guid>
<comments>https://example.railgun.sh/articles/3#comments</comments>
<atom:author>
<atom:name>Anonymous</atom:name>
</atom:author>
</item>
</channel>
</rss>
XML;
public function testFeedBuilder(): void {
$feed = new FeedBuilder;
$this->assertEquals(self::BLANK_FEED, $feed->toXmlString());
$feed->setTitle('Index RSS');
$feed->setDescription('<b>This should accept HTML!</b>');
$feed->setUpdatedAt(1727985985);
$feed->setContentUrl('https://example.railgun.sh/articles');
$feed->setFeedUrl('https://example.railgun.sh/feed.xml');
$this->assertEquals(self::NO_ITEMS_FEED, $feed->toXmlString());
$feed->createEntry(function($item) {
$item->setTitle('Article 1');
$item->setDescription(<<<HTML
<p>This is an article!</p>
<p>It has <b>HTML</b> and <em>multiple lines</em> and all that <u>awesome</u> stuff!!!!</p>
<h2>Meaning it can also have sections</h2>
<p>Technology is so cool...</p>
HTML);
$item->setCreatedAt(1727485985);
$item->setContentUrl('https://example.railgun.sh/articles/1');
$item->setCommentsUrl('https://example.railgun.sh/articles/1#comments');
$item->setAuthorName('flashwave');
$item->setAuthorUrl('https://example.railgun.sh/author/1');
});
$feed->createEntry(function($item) {
$item->setTitle('Article 2');
$item->setDescription(<<<HTML
<p>hidden author spooky</p>
HTML);
$item->setCreatedAt(1727585985);
$item->setContentUrl('https://example.railgun.sh/articles/2');
$item->setCommentsUrl('https://example.railgun.sh/articles/2#comments');
});
$feed->createEntry(function($item) {
$item->setTitle('Article 3 without a description');
$item->setCreatedAt(1727685985);
$item->setContentUrl('https://example.railgun.sh/articles/3');
$item->setCommentsUrl('https://example.railgun.sh/articles/3#comments');
$item->setAuthorName('Anonymous');
});
$this->assertEquals(self::ITEMS_FEED, $feed->toXmlString());
}
}