Skip to content

Instantly share code, notes, and snippets.

@yookoala
Last active January 13, 2022 03:22
Show Gist options
  • Save yookoala/e39bf598ed8acbd8d8ccb1027a5e79eb to your computer and use it in GitHub Desktop.
Save yookoala/e39bf598ed8acbd8d8ccb1027a5e79eb to your computer and use it in GitHub Desktop.
A simple Monogatari remote storage implementation
<?php
namespace Monogatari\RemoteStorage;
/**
* Throw this if a key is not found in a StorageInterface.
*/
class StorageKeyNotFound extends \Exception
{
/**
* Key that is not found in the storage.
*
* @var string
*/
private $key = '';
public function __construct(string $key)
{
parent::__construct("key \"{$key}\" not found in the storage");
$this->key = $key;
}
/**
* Getting the key that is not found.
*
* @return string
*/
public function key(): string
{
return $this->key;
}
}
/**
* StorageInterface stores key-value pairs.
*/
interface StorageInterface
{
/**
* Get the entire storage object.
*
* @return object
*/
public function getAll(): object;
/**
* Get the value of a certain key
*
* @param string $key The key of the attribute of the storage object.
*
* @return mixed The value of the key.
*
* @throws StorageKeyNotFound
*/
public function get(string $key);
/**
* Set the key in the storage to the given value.
* Need to run save() to confirm.
*
* @param string $key
* @param mixed $value
*
* @return self
*/
public function set(string $key, $value);
/**
* Remove a key from the storage.
* Need to run save() to confirm.
*
* @return self
*/
public function remove(string $key);
/**
* Clear the underly storage facility.
*
* @return boolean If the storage is successfully cleared.
*/
public function clear(): bool;
/**
* Save the content into the underlying storage facility.
*
* @return boolean If the storage update is successfully saved.
*/
public function save(): bool;
}
/**
* FileSystemStorage stores data in file.
*/
class FileSystemStorage implements StorageInterface
{
/**
* Path to store the data.
*
* @var string
*/
private $path;
/**
* Data object to save / load.
*
* @var object
*/
protected $data;
/**
* Constructor
*
* @param string $path The path of the data stored.
*/
public function __construct(string $path)
{
$this->path = $path;
$this->data = $this->getAllFromFile();
}
/**
* {@inheritDoc}
*/
function getAll(): object
{
return $this->data;
}
/**
* {@inheritDoc}
*
* @throws \JsonException If the content stored cannot be parsed as json.
*/
public function get(string $key)
{
$data = $this->getAll();
if (empty($data->{$key})) {
throw new StorageKeyNotFound($key);
}
return $data->{$key};
}
/**
* {@inheritDoc}
*/
public function set(string $key, $value)
{
$this->data->{$key} = $value;
return $this;
}
/**
* {@inheritDoc}
*/
public function remove(string $key)
{
unset($this->data->{$key});
return $this;
}
/**
* {@inheritDoc}
*/
public function clear(): bool
{
return unlink($this->path);
}
/**
* {@inheritDoc}
*/
public function save(): bool
{
$dir = dirname($this->path);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
if (!is_writable($dir)) {
throw new \Exception('Unable to write to directory: ' . $dir);
}
// encode data to json string and store to the file.
$json = json_encode($this->data, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
return (file_put_contents($this->path, $json) !== false);
}
/**
* @throws \JsonException If the content stored cannot be parsed as json.
*/
private function getAllFromFile(): object
{
// If file not found.
if (!is_file($this->path)) {
return new \stdClass();
}
// If the file is empty.
$contents = file_get_contents($this->path);
if (empty($contents)) {
// Corrupted or otherwise misformated.
// TODO: throw proper exception.
return new \stdClass();
}
$data = json_decode($contents, false, 512, JSON_THROW_ON_ERROR);
if (!is_object($data)) {
// Corrupted or otherwise misformated.
// TODO: throw proper exception.
return new \stdClass();
}
return $data;
}
}
/**
* An implementation of the server side of
* Monogatari's RemoteStorage solution.
*/
class Controller
{
/**
* Internal storage.
*
* @var StorageInterface
*/
private $storage;
/**
* Constructor.
*
* @param StorageInterface $storage The internal storage to use.
*/
public function __construct(StorageInterface $storage)
{
$this->storage = $storage;
}
/**
* Handle the request generated by requestFromEnvironment
* or equivlant input.
*
* @param array $request
* @return void
*/
public function handleRequest(array $request)
{
error_log('request = ' . var_export($request, true));
// if this is a post reqeust, store the body.
switch ($request['method']) {
case 'GET':
// Gather data
$data = empty($request['key'])
? $this->storage->getAll()
: static::mustGet(fn() => $this->storage->get($request['key']), new \stdClass());
// if this is a listing request, list all the keys.
return static::jsonResponse($request['listing_mode'] ? array_keys(get_object_vars($data)) : $data);
break;
case 'POST':
case 'PUT':
if (empty($request['key'])) {
throw new \Exception('Must specify key to save content');
}
// Parse the value as JSON
$value = json_decode($request['body'], null, 512, JSON_THROW_ON_ERROR);
// Store the data
if ($this->storage->set($request['key'], $value)->save()) {
return static::jsonResponse($value); // accepted.
}
// Response with internal error.
return static::jsonResponse([
'code' => 500,
'status' => 'error',
'error' => 'internal error',
'error_description' => 'unable to save to internal storage'
], 500);
break;
case 'PATCH':
if (empty($request['key'])) {
throw new \Exception('Must specify key to update with');
}
// Parse the value as JSON
$value = json_decode($request['body'], null, 512, JSON_THROW_ON_ERROR);
// Store the data
try {
$old_value = $this->storage->get($request['key']);
} catch (StorageKeyNotFound $e) {
$old_value = new \stdClass();
}
$value = array_merge(get_object_vars($value), get_object_vars($old_value));
if ($this->storage->set($request['key'], $value)->save()) {
return static::jsonResponse($value); // accepted.
}
// Response with internal error.
return static::jsonResponse([
'code' => 500,
'status' => 'error',
'error' => 'internal error',
'error_description' => 'unable to save to internal storage'
], 500);
break;
case 'DELETE':
if (empty($request['key'])) {
if ($this->storage->clear()) {
// action has been enacted and the response message includes
// a representation describing the status.
return static::jsonResponse([
'code' => 200,
'status' => 'success',
], 200);
} else {
return static::jsonResponse([
'code' => 500,
'status' => 'error',
'error' => 'internal error',
'error description' => 'unable to clear the storage',
], 500);
}
}
// Find existing content of the key
try {
$data = $this->storage->get($request['key']);
} catch (StorageKeyNotFound $e) {
return static::jsonResponse([
'code' => 404,
'status' => 'error',
'error' => 'not found',
], 404);
}
if ($this->storage->remove($request['key'])->save()) {
// action has been enacted and the response message includes
// a representation describing the status.
return static::jsonResponse([
'code' => 200,
'status' => 'success',
], 200);
}
// report internal error
return static::jsonResponse([
'code' => 500,
'status' => 'error',
'error' => 'internal error',
], 500);
break;
default:
return static::jsonResponse([
'code' => 405,
'status' => 'error',
'error' => 'method not allowed',
], 405);
}
}
/**
* Gather the request from environment.
*
* @return array
*/
public static function requestFromEnvironment(): array
{
$pathinfo = trim($_SERVER['PATH_INFO'] ?? '', "\n\r\t ");
$request_body = file_get_contents('php://input');
if (!preg_match('/^\/((.+)\/|)(.*?)$/', $pathinfo, $matches)) {
throw new \Exception('Unable to parse pathinfo: ' . $pathinfo);
}
$store_name = $matches[2];
$key = $matches[3];
$listing_mode = ($_GET['keys'] ?? null) === 'true';
return [
'method' => $_SERVER['REQUEST_METHOD'],
'store_name' => $store_name,
'key' => $key,
'query' => $_SERVER['QUERY_STRING'] ?? '',
'listing_mode' => $listing_mode,
'body' => $request_body,
];
}
/**
* Call a callable, handles any exception.
*
* @param callable $do
*
* @return mixed|void
*/
public static function handleExceptionIn(callable $do)
{
try {
return $do();
} catch (\Exception $e) {
error_log('exception: ' . $e->getMessage() . "\nstacktrace: " . $e->getTraceAsString());
return static::jsonResponse([
'code' => 500,
'status' => 'error',
'error' => 'internal error',
'message' => $e->getMessage(),
], 500);
}
}
/**
* Create a JSON response from the given data.
*
* @param mixed $data
* @param int $status_code
*
* @return void
*/
private static function jsonResponse($data, int $status_code = 200)
{
$response_body = json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
http_response_code($status_code);
header('Content-Type: application/json; charset=utf-8');
header('Content-Length: ' . strlen($response_body));
exit($response_body);
}
/**
* Undocumented function
*
* @param callable $cb A callable that can throw StorageKeyNotFound exception.
* @param mixed $fallback A fallback value if StorageKeyNotFound is thrown by the callable.
*
* @return mixed
*/
private static function mustGet(callable $cb, $fallback)
{
try {
return $cb();
} catch (StorageKeyNotFound $e) {
return $fallback;
}
}
}
// Basic usage of the library.
$controller = new Controller(new FileSystemStorage('./gameSave.json'));
Controller::handleExceptionIn(fn() => $controller->handleRequest(Controller::requestFromEnvironment()));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment