Last active
January 13, 2022 03:22
-
-
Save yookoala/e39bf598ed8acbd8d8ccb1027a5e79eb to your computer and use it in GitHub Desktop.
A simple Monogatari remote storage implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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