Skip to content
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,20 @@ Our SDKs include code to track the following information by adding it to the req

We use this data to enable better support and feature planning.

### Internal Metadata for Infrastructure Routing

For Data API requests, the SDK automatically includes additional internal metadata that is used solely by Learnosity infrastructure for routing and operational purposes:

- **API Consumer**: The consumer key identifier from your security packet
- **Action**: The operation being performed (e.g., `get_/itembank/items`, `set_/session_scores`)

This metadata is:
- **Completely invisible to customers** - it requires no changes to your code or implementation
- **Used only for internal infrastructure routing** - it helps our systems make intelligent routing decisions at the Application Load Balancer (ALB) layer
- **Automatically derived** - the action is constructed from the endpoint URL and HTTP method you're already using

This functionality is built into the SDK and works transparently with all existing Data API integrations.

[(Back to top)](#table-of-contents)

## Further reading
Expand Down
4 changes: 4 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ You can also download the entire demo site or browse the code directly on [GitHu

The Init class is used to create the necessary *security* and *request* details used to integrate with a Learnosity API. Most often this will be a JavaScript object.

For Data API requests, the Init class automatically includes internal metadata (consumer identifier and action) in the request packet. This metadata is used by Learnosity infrastructure for routing decisions and is completely transparent to your application.

``` php
//Include the Init and Uuid classes.
use LearnositySdk\Request\Init;
Expand Down Expand Up @@ -182,6 +184,8 @@ Returns the HTTP status code of the response.

This is a helper class for use with the Data API. It creates the initialisation packet and sends a request to the Data API, returning an instance of Remote. You can then interact as you would with Remote, e.g., `getBody()`

**Automatic Metadata Inclusion**: The DataApi class automatically includes internal metadata in every request for infrastructure routing purposes. This metadata includes the consumer identifier and the action being performed (derived from the endpoint and HTTP method). This functionality is completely transparent to your application and requires no code changes.

#### request()

Used for a single request to the Data API. You can call as many times as necessary. It will return a `Remote` object, on which `getBody()` needs to be called to get the contents of the response.
Expand Down
4 changes: 3 additions & 1 deletion src/Request/DataApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ public function request(
array $requestPacket = [],
?string $action = null
): RemoteInterface {
$init = new Init('data', $securityPacket, $secret, $requestPacket, $action);
// Pass endpoint to Init for automatic metadata generation (consumer/action)
// The two null values are for optional SignatureFactory and PreHashStringFactory parameters
$init = new Init('data', $securityPacket, $secret, $requestPacket, $action, null, null, $endpoint);
$params = $init->generate();
return $this->remote->post($endpoint, $params);
}
Expand Down
71 changes: 65 additions & 6 deletions src/Request/Init.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ class Init
*/
private $action;

/**
* The endpoint URL for Data API requests, used to derive action metadata
* @var string|null
*/
private $endpoint;

/**
* Most services add the request packet (if passed) to the signature
* for security reasons. This flag can override that behaviour for
Expand Down Expand Up @@ -125,6 +131,8 @@ class Init
* @param string|array $requestPacket
* @param string $action
* @param SignatureFactory|null $signatureFactory
* @param PreHashStringFactory|null $preHashStringFactory
* @param string|null $endpoint
* @throws ValidationException
*/
public function __construct(
Expand All @@ -134,7 +142,8 @@ public function __construct(
$requestPacket = null,
?string $action = null,
?SignatureFactory $signatureFactory = null,
?PreHashStringFactory $preHashStringFactory = null
?PreHashStringFactory $preHashStringFactory = null,
?string $endpoint = null
) {
$this->signatureFactory = $signatureFactory;
if (!isset($signatureFactory)) {
Expand All @@ -154,6 +163,7 @@ public function __construct(
$this->secret = $secret;
$this->requestPacket = $requestPacket;
$this->action = $action;
$this->endpoint = $endpoint;
$this->validate();

if (self::$telemetryEnabled) {
Expand All @@ -178,7 +188,9 @@ public function __construct(
* "lang_version": "5.6.36",
* "platform": "Linux",
* "platform_version": "3.10.0-862.6.3.el7.x86_64"
* }
* },
* "consumer": "consumer_key_value",
* "action": "get_/itembank/items"
* }
* }
*
Expand All @@ -193,12 +205,24 @@ private function addMeta()
'platform_version' => php_uname('r')
];

$meta = [
'sdk' => $sdkMetricsMeta
];

// Add consumer metadata for Data API requests
if ($this->service === 'data' && isset($this->securityPacket['consumer_key'])) {
$meta['consumer'] = $this->securityPacket['consumer_key'];
}

// Add action metadata for Data API requests
if ($this->service === 'data') {
$meta['action'] = $this->deriveAction();
}

if (isset($this->decodedRequestPacket['meta'])) {
$this->decodedRequestPacket['meta']['sdk'] = $sdkMetricsMeta;
$this->decodedRequestPacket['meta'] = array_merge($this->decodedRequestPacket['meta'], $meta);
} else {
$this->decodedRequestPacket['meta'] = [
'sdk' => $sdkMetricsMeta
];
$this->decodedRequestPacket['meta'] = $meta;
}
$this->requestPacket = Json::encode($this->decodedRequestPacket);
}
Expand All @@ -215,6 +239,41 @@ private function getSDKVersion(): string
return trim(file_get_contents(self::VERSION_FILE_PATH));
}

/**
* Derives the action metadata from the endpoint URL and HTTP method.
* Format: {method}_{path} (e.g., "get_/itembank/items", "set_/session_scores")
*
* @return string
*/
private function deriveAction(): string
{
if (empty($this->endpoint)) {
return $this->action ?? 'unknown';
}

// Extract the path from the endpoint URL using regex for security
// Match protocol://domain/path pattern and extract the path component
if (!preg_match('#^https?://[^/]+(/.*)?$#', $this->endpoint, $matches)) {
// Invalid URL format, fallback to action or unknown
return $this->action ?? 'unknown';
}
$path = $matches[1] ?? '/';

// Remove version information from the path
// (e.g., /v2023.1.lts/itembank/items -> /itembank/items, /v1/sessions -> /sessions, /developer/items -> /items)
$path = preg_replace('/\/(v\d+(\.\d+)*(\.[a-zA-Z]+)?|latest(-lts)?|developer)/', '', $path);

// Ensure path starts with /
if (!empty($path) && $path[0] !== '/') {
$path = '/' . $path;
}

// Use the action parameter if provided, otherwise default to 'get'
$method = $this->action ?? 'get';

return $method . '_' . $path;
}

/**
* Generate the data necessary to make a request to one of the
* Learnosity products/services.
Expand Down
43 changes: 43 additions & 0 deletions src/Request/Remote.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace LearnositySdk\Request;

use Exception;
use LearnositySdk\Utils\Conversion;

/**
Expand Down Expand Up @@ -107,6 +108,45 @@ private function normalizeRequestHeaders(array $headers): array
return $headers;
}

/**
* Add metadata headers for ALB layer access from Data API request data.
*
* @param array $headers - the existing headers array
* @param array $post - the POST data payload
* @return array - headers with metadata added
*/
private function addMetadataHeaders(array $headers, array $post): array
{
// Only process Data API requests (they have 'request' and 'security' keys)
if (!isset($post['request']) || !isset($post['security'])) {
return $headers;
}

try {
// Decode the request packet to extract metadata
$requestData = json_decode($post['request'], true);
if (!is_array($requestData) || !isset($requestData['meta'])) {
return $headers;
}

$meta = $requestData['meta'];

// Add consumer header if available
if (isset($meta['consumer'])) {
$headers[] = 'X-Learnosity-Consumer: ' . $meta['consumer'];
}

// Add action header if available
if (isset($meta['action'])) {
$headers[] = 'X-Learnosity-Action: ' . $meta['action'];
}
} catch (Exception $e) {
// Silently ignore JSON decode errors to avoid breaking requests
}

return $headers;
}

/**
* Makes a cURL request to an endpoint with an optional request
* payload and cURL options.
Expand All @@ -127,6 +167,9 @@ public function request(string $url, array $post = [])

$options = array_merge($defaults, $this->remoteOptions);

// Add metadata headers for ALB layer access
$options['headers'] = $this->addMetadataHeaders($options['headers'], $post);

// normalize the headers
$options['headers'] = $this->normalizeRequestHeaders($options['headers']);

Expand Down
92 changes: 92 additions & 0 deletions tests/Request/DataApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,98 @@ function (array $data) use (&$requestCount, &$results) {
$this->assertEquals(5, $requestCount);
}

/**
* Test that DataApi passes endpoint information to Init for metadata generation
*/
public function testDataApiPassesEndpointForMetadata()
{
// Create a mock Remote to capture the request data
$mockRemote = new class implements RemoteInterface {
public $capturedUrl = '';
public $capturedData = [];

public function get(string $url, array $data = []): RemoteInterface
{
return $this;
}

public function post(string $url, array $data = []): RemoteInterface
{
$this->capturedUrl = $url;
$this->capturedData = $data;
return $this;
}

public function request(string $url, array $post = [])
{
// Suppress unused parameter warnings - required by interface
unset($url, $post);
// Not used in this test
}

public function getBody()
{
return '{"meta":{"status":true},"data":[]}';
}

public function getError(): array
{
return ['code' => 0, 'message' => ''];
}

public function getHeader(string $type = 'content_type')
{
// Suppress unused parameter warning - required by interface
unset($type);
return 'application/json';
}

public function getSize(bool $format = true)
{
// Suppress unused parameter warning - required by interface
unset($format);
return 100;
}

public function getStatusCode(): int
{
return 200;
}

public function getTimeTaken(): float
{
return 0.1;
}

public function json(bool $assoc = true)
{
return json_decode($this->getBody(), $assoc);
}
};

$securityPacket = [
'consumer_key' => 'test_consumer_key',
'domain' => 'localhost'
];

$requestPacket = ['limit' => 20];
$endpoint = 'https://data.learnosity.com/v2023.1.lts/itembank/items';

$dataApi = new DataApi([], $mockRemote);
$dataApi->request($endpoint, $securityPacket, 'test_secret', $requestPacket, 'get');

// Verify the request data contains metadata
$this->assertEquals($endpoint, $mockRemote->capturedUrl);
$this->assertArrayHasKey('request', $mockRemote->capturedData);

$decodedRequest = json_decode($mockRemote->capturedData['request'], true);
$this->assertArrayHasKey('meta', $decodedRequest);
$this->assertArrayHasKey('consumer', $decodedRequest['meta']);
$this->assertArrayHasKey('action', $decodedRequest['meta']);
$this->assertEquals('test_consumer_key', $decodedRequest['meta']['consumer']);
$this->assertEquals('get_/itembank/items', $decodedRequest['meta']['action']);
}

public function testRequestRecursiveArrayMerge()
{
$expectedResult = <<<JSON
Expand Down
Loading