Skip to content

Commit

Permalink
Add JSON storage class & API access
Browse files Browse the repository at this point in the history
Add a generic JSON-based storage class and an extension of it that
will be used in the API to store blobs from clients. This is a similar
structure to the Settings class / user_settings table but enforces
a JSON value.

This creates a shortcut in the API to allow JSON input to bypass
being deserialized and the output to bypass being serialized and
allow the routing function to manage that.
  • Loading branch information
cpeel committed Jan 18, 2025
1 parent 18dce22 commit e2919d8
Show file tree
Hide file tree
Showing 17 changed files with 440 additions and 15 deletions.
22 changes: 22 additions & 0 deletions SETUP/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,25 @@ Three settings in `configuration.sh` control limiting:
allowed per given window.
* `_API_RATE_LIMIT_SECONDS_IN_WINDOW` - the number of seconds within a given
window.

## Storage

To facilitate javascript UI clients persisting data across browsers and devices,
the API includes an optional endpoint for clients to store and fetch JSON blobs.
To enable this feature, add a storage key to the `_API_STORAGE_KEYS`
configuration setting and have the client use that string with the endpoint
as the `storagekey`.

Some important notes about this feature:
* API storage is one blob per user per storage key. Said another way: API users are
only able to store one blob per `storagekey` and that blob can only be
set and retrieved by the user authenticated with the API.
* Beyond validating these are valid JSON objects, they are treated as opaque
blobs server-side. It is up to the client to manage the object, including
the schema and the possibility that the object will not match an expected
schema.
* When used inside javascript in the browser, the `storagekey` is visible to
the browser user and is therefore not a secret. Nothing prevents users with
API keys (or valid PHP session keys) from using this endpoint with a valid
storage key to change the contents of this blob for their user. API users
should treat this blob as unvalidated user input and act accordingly.
1 change: 1 addition & 0 deletions SETUP/configuration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ _API_ENABLED=true
_API_RATE_LIMIT=false
_API_RATE_LIMIT_REQUESTS_PER_WINDOW=3600
_API_RATE_LIMIT_SECONDS_IN_WINDOW=3600
_API_STORAGE_KEYS='[]'

# XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Expand Down
12 changes: 12 additions & 0 deletions SETUP/db_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@ CREATE TABLE `job_logs` (
KEY `timestamp` (`tracetime`, `succeeded`)
);

--
-- Table structure for table `json_storage`
--

CREATE TABLE `json_storage` (
`username` varchar(25) NOT NULL,
`setting` varchar(32) NOT NULL,
`value` json NOT NULL,
`timestamp` int NOT NULL DEFAULT '0',
PRIMARY KEY (`username`,`setting`)
);

--
-- Table structure for table `news_items`
--
Expand Down
1 change: 1 addition & 0 deletions SETUP/tests/ci_configuration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ _API_ENABLED=true
_API_RATE_LIMIT=false
_API_RATE_LIMIT_REQUESTS_PER_WINDOW=3600
_API_RATE_LIMIT_SECONDS_IN_WINDOW=3600
_API_STORAGE_KEYS='[]'

_EXTERNAL_CATALOG_LOCATOR='z3950.loc.gov:7090/Voyager'

Expand Down
47 changes: 46 additions & 1 deletion SETUP/tests/unittests/ApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,9 @@ public function test_pickersets(): void
$this->assertEquals(["¿", "INVERTED QUESTION MARK"], $pickerset["subsets"][3]["rows"][1][1]);
}

//---------------------------------------------------------------------------
// tests for documents

public function test_available_italian_documents(): void
{
$path = "v1/documents";
Expand Down Expand Up @@ -823,11 +826,53 @@ public function test_unavailable_document(): void
$_SERVER["REQUEST_METHOD"] = "GET";
$router->route($path, ['language_code' => 'de']);
}

//---------------------------------------------------------------------------
// tests for storage

public function test_storage_valid(): void
{
global $pguser;
global $api_storage_keys;
global $request_body;

$pguser = $this->TEST_USERNAME_PM;
array_push($api_storage_keys, "valid");

$path = "v1/storage/valid";
$query_params = [];
$request_body = json_encode(["key" => 1]);
$router = ApiRouter::get_router();

$_SERVER["REQUEST_METHOD"] = "PUT";
$response = $router->route($path, $query_params);
$this->assertEquals(json_decode($request_body), json_decode($response));

$_SERVER["REQUEST_METHOD"] = "GET";
$response = $router->route($path, $query_params);
$this->assertEquals(json_decode($request_body), json_decode($response));

$_SERVER["REQUEST_METHOD"] = "DELETE";
$response = $router->route($path, $query_params);
$this->assertEquals(null, $response);
}

public function test_storage_invalid(): void
{
$this->expectExceptionCode(4);

$query_params = [];

$path = "v1/storage/invalid";
$_SERVER["REQUEST_METHOD"] = "GET";
$router = ApiRouter::get_router();
$router->route($path, $query_params);
}
}

// this mocks the function in index.php
/** @return string|array */
function api_get_request_body()
function api_get_request_body(bool $raw = false)
{
global $request_body;
return $request_body;
Expand Down
50 changes: 50 additions & 0 deletions SETUP/tests/unittests/StorageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

class StorageTest extends PHPUnit\Framework\TestCase
{
//------------------------------------------------------------------------
// Basic JSON storage test

public function test_valid_json(): void
{
$storage = new JsonStorage("username");
$storage->set("setting", "{}");
$value = $storage->get("setting");
$this->assertEquals("{}", $value);
$value = $storage->delete("setting");
$this->assertEquals(null, $value);
}

public function test_invalid_json(): void
{
$this->expectException(ValueError::class);
$storage = new JsonStorage("username");
$storage->set("setting", "blearg");
}

//------------------------------------------------------------------------
// API storage test

public function test_valid_storagekey(): void
{
global $api_storage_keys;
$api_storage_keys = ["valid"];

$storage = new ApiStorage("valid", "username");
$storage->set("{}");
$value = $storage->get();
$this->assertEquals("{}", $value);
$value = $storage->delete();
$this->assertEquals(null, $value);
}

public function test_invalid_storagekey(): void
{
global $api_storage_keys;
$api_storage_keys = [];

$this->expectException(ValueError::class);

$storage = new ApiStorage("invalid", "username");
}
}
23 changes: 23 additions & 0 deletions SETUP/upgrade/22/20241219_create_json_storage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
$relPath = '../../../pinc/';
include_once($relPath.'base.inc');

// ------------------------------------------------------------

echo "Creating json_storage table...\n";

$sql = "
CREATE TABLE json_storage (
username varchar(25) NOT NULL,
setting varchar(32) NOT NULL,
value json NOT NULL,
timestamp int NOT NULL default 0,
PRIMARY KEY (username, setting)
)
";

mysqli_query(DPDatabase::get_connection(), $sql) or die(mysqli_error(DPDatabase::get_connection()));

// ------------------------------------------------------------

echo "\nDone!\n";
35 changes: 34 additions & 1 deletion api/ApiRouter.inc
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ class ApiRouter
private TrieNode $root;
/** @var array<string, callable> */
private $_validators;
/** @var mixed */
private $_response;
private bool $_raw_response = false;

public function __construct()
{
Expand Down Expand Up @@ -74,7 +77,8 @@ class ApiRouter
if (!$handler) {
throw new MethodNotAllowed();
}
return $handler($method, $data, $query_params);
$this->_response = $handler($method, $data, $query_params);
return $this->_response;
}

public function add_validator(string $label, callable $function): void
Expand All @@ -93,6 +97,35 @@ class ApiRouter
throw new InvalidAPI();
}

/** @return mixed */
public function request(bool $raw = false)
{
if ($raw) {
return file_get_contents('php://input');
} else {
$json_object = json_decode(file_get_contents('php://input'), true);
if ($json_object === null) {
throw new InvalidValue("Content was not valid JSON");
}
return $json_object;
}
}

public function response(bool $raw = false): string
{
if ($raw || $this->_raw_response) {
return $this->_response;
} else {
return json_encode($this->_response, JSON_PRETTY_PRINT |
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}

public function set_raw_response(): void
{
$this->_raw_response = true;
}

public static function get_router(): ApiRouter
{
/** @var ?ApiRouter */
Expand Down
52 changes: 52 additions & 0 deletions api/dp-openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,58 @@ paths:
404:
$ref: '#/components/responses/NotFound'

/storage/{storagekey}:
get:
tags:
- storage
description: Get JSON blob stored for this storage key
parameters:
- name: storagekey
in: path
description: Storage key
required: true
schema:
type: string
responses:
200:
description: JSON blob
content:
application/json:
schema:
type: object
put:
tags:
- storage
description: Save JSON blob for this storage key
parameters:
- name: storagekey
in: path
description: Storage key
required: true
schema:
type: string
responses:
200:
description: JSON blob that was persisted
content:
application/json:
schema:
type: object
delete:
tags:
- storage
description: Delete JSON blob for this storage key
parameters:
- name: storagekey
in: path
description: Storage key
required: true
schema:
type: string
responses:
200:
description: JSON blob was deleted

components:
securitySchemes:
ApiKeyAuth:
Expand Down
24 changes: 12 additions & 12 deletions api/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ function api()
unset($query_params["url"]);

$router = ApiRouter::get_router();

api_output_response($router->route($path, $query_params));
$router->route($path, $query_params);
api_output_response($router->response());
}

function api_authenticate()
Expand Down Expand Up @@ -127,20 +127,16 @@ function api_rate_limit($key)
header("X-Rate-Limit-Reset: $seconds_before_reset");
}

function api_get_request_body()
function api_get_request_body(bool $raw = false)
{
$json = json_decode(file_get_contents('php://input'), true);
if ($json === null) {
throw new InvalidValue("Content was not valid JSON");
}
return $json;
$router = ApiRouter::get_router();
return $router->request($raw);
}

function api_output_response($data, $response_code = 200)
function api_output_response(string $data, int $response_code = 200)
{
http_response_code($response_code);
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE |
JSON_UNESCAPED_SLASHES);
echo $data;

// output the output buffer we've been storing to ensure we could
// send the right HTTP response code
Expand Down Expand Up @@ -250,7 +246,11 @@ function production_exception_handler($exception)
$response_code = 500;
}

api_output_response(["error" => $exception->getMessage(), "code" => $exception->getCode()], $response_code);
$response = json_encode(
["error" => $exception->getMessage(), "code" => $exception->getCode()],
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
api_output_response($response, $response_code);
}

function test_exception_handler($exception)
Expand Down
6 changes: 6 additions & 0 deletions api/v1.inc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ include_once("v1_projects.inc");
include_once("v1_queues.inc");
include_once("v1_stats.inc");
include_once("v1_docs.inc");
include_once("v1_storage.inc");

$router = ApiRouter::get_router();

Expand All @@ -18,6 +19,7 @@ $router->add_validator(":pagename", "validate_page_name");
$router->add_validator(":pageroundid", "validate_page_round");
$router->add_validator(":queueid", "validate_release_queue");
$router->add_validator(":document", "validate_document");
$router->add_validator(":storagekey", "validate_storage_key");

// Add routes
$router->add_route("GET", "v1/documents", "api_v1_documents");
Expand Down Expand Up @@ -62,3 +64,7 @@ $router->add_route("GET", "v1/stats/site/projects/stages", "api_v1_stats_site_pr
$router->add_route("GET", "v1/stats/site/projects/states", "api_v1_stats_site_projects_states");
$router->add_route("GET", "v1/stats/site/rounds", "api_v1_stats_site_rounds");
$router->add_route("GET", "v1/stats/site/rounds/:roundid", "api_v1_stats_site_round");

$router->add_route("GET", "v1/storage/:storagekey", "api_v1_storage");
$router->add_route("PUT", "v1/storage/:storagekey", "api_v1_storage");
$router->add_route("DELETE", "v1/storage/:storagekey", "api_v1_storage_delete");
Loading

0 comments on commit e2919d8

Please sign in to comment.