Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add client JSON storage API endpoint #1405

Merged
merged 1 commit into from
Jan 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need this line now?

}

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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function could stay in index.php

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved this here to keep the encoding and decoding together. And because I had to move the encoding here this came along with it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems reasonable. I've had some further thoughts about this and deleted my other ananswered comments which had overlooked a change you made in index.php. It's difficult to see the whole picture in this github view so I dowloaded the files to look at them more carefully.

{
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We wouldn't need this if it was incorporated as above.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because I wanted to keep the encoding and decoding together and they got moved in here, breaking them out into clear functions makes it clearer what's going on IMHO.

{
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
Comment on lines +1373 to +1377
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it make sense to return the timestamp (something the client could use for caching) rather than the JSON blob that was sent by the client?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timestamp is arguably just as useless as the JSON blob because it's always now(). I was uncertain what a good return value would be so I modeled what we do for PUTs for word lists -- and what the product at my job does for PUTs and PATCHes -- which is to return the object that was actually serialized.

Open to ideas / thoughts on what API clients should "expect" from a successful PUT / PATCH / etc beyond the HTTP status code.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timestamp is arguably just as useless as the JSON blob because it's always now().

Sure as it represents the last modified timestamp from the server's perspective.

However I do think there is a lot more value to returning this compared to the blob that was sent and is known to the client. The timestamp would allow the client to implement some caching (e.g. don't check with the server if the timestamp is fresh enough), lower the bandwidth needed (by return the JSON object on a get request if the timestamp doesn't match, useful for mobile devices) or potentially even allow for delta (this would need to switch to some event based logic). Arguably, neither of those scenarios applies here, but those are enabled by returning the timestamp 😄

Open to ideas / thoughts on what API clients should "expect" from a successful PUT / PATCH / etc beyond the HTTP status code.

I don't think we need to return anything from the API on success: the return code says if it was successful and we can return an optional error field to return the cause of errors to the client. Those 2 are what a minimal client would expect. We can always add more as we understand our needs more.

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