diff --git a/README.md b/README.md index e50f5c1..eefea28 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ An official Learnosity open-source project.

* [Requirements](#requirements) * [Installation](#installation) * [Quick start guide](#quick-start-guide) +* [Data API Routing Layer](#data-api-routing-layer) * [Next steps: additional documentation](#next-steps-additional-documentation) * [Contributing to this project](#contributing-to-this-project) * [License](#license) @@ -29,7 +30,7 @@ The Learnosity Node.js SDK makes it simple to interact with Learnosity APIs. It provides a number of convenience features for developers, that make it simple to do the following essential tasks: * Creating signed security requests for API initialization, and -* Interacting with the Data API. +* Interacting with the Data API (now includes a routing layer for making HTTP requests). For example, the SDK helps with creating a signed request for Learnosity: @@ -133,20 +134,20 @@ Let's take a look at a simple example of the SDK in action. In this example, we' ### **Start up your web server and view the standalone assessment example** To start up your Node.js web server, first find the following folder location under the SDK. Change directory ('cd') to this location on the command line. - .../learnosity-sdk-nodejs/docs/quickstart/assessment/ + .../learnosity-sdk-nodejs/docs/quickstart/ To start, run this command from that folder: ``` -npm run start-standalone-assessment +npm start ``` -From this point on, we'll assume that your web server is available at this local address (it will report the port being used when you launch it, by default it is port 3000): +From this point on, we'll assume that your web server is available at this local address (it will report the port being used when you launch it, by default it is port 8000): -http://localhost:3000/ +http://localhost:8000/ -When you open this URL with your browser, the page will load. This is a basic example of an assessment loaded into a web page with Learnosity's assessment player. You can interact with this demo assessment to try out the various Question types. +When you open this URL with your browser, you'll see a menu of interactive examples demonstrating different Learnosity APIs. Click on "Items API" to view the standalone assessment example, which loads an assessment into a web page with Learnosity's assessment player. You can interact with this demo assessment to try out the various Question types. @@ -292,6 +293,51 @@ Take a look at some more in-depth options and tutorials on using Learnosity asse [(Back to top)](#table-of-contents) +## Data API Routing Layer + +The SDK now includes a routing layer for the Data API, making it easy to interact with Learnosity's Data API without manually handling HTTP requests. + +### Quick Example + +```javascript +const DataApi = require('learnosity-sdk-nodejs/lib/DataApi'); + +const dataApi = new DataApi({ + consumerKey: 'your_consumer_key', + consumerSecret: 'your_consumer_secret', + domain: 'yourdomain.com' +}); + +// Make a request +const response = await dataApi.request( + 'https://data.learnosity.com/v2023.1.LTS/itembank/items', + { consumer_key: 'xxx', domain: 'example.com' }, + 'secret', + { limit: 10 }, + 'get' +); + +const data = await response.json(); +console.log(data); + +// Or iterate through all pages automatically +for await (const page of dataApi.requestIter(endpoint, security, secret, request, 'get')) { + console.log(`Page has ${page.data.length} items`); +} +``` + +### Features + +- ✅ Automatic request signing +- ✅ Built-in HTTP client +- ✅ Pagination support with iterators +- ✅ Routing metadata headers +- ✅ Custom HTTP adapter support + +See the [Data API documentation](docs/DataApi.md) for complete details and examples. + +[(Back to top)](#table-of-contents) + ## Next steps: additional documentation ### **SDK reference** diff --git a/docs/DataApi.md b/docs/DataApi.md new file mode 100644 index 0000000..e6fe565 --- /dev/null +++ b/docs/DataApi.md @@ -0,0 +1,192 @@ +# Data API Routing Layer + +The Node.js SDK now includes a routing layer for the Data API, making it easier to interact with Learnosity's Data API without manually handling HTTP requests and signatures. + +## Features + +- ✅ **Automatic request signing** - No need to manually sign requests +- ✅ **Built-in HTTP client** - Makes actual HTTP requests to Data API +- ✅ **Pagination support** - Automatically handles paginated responses +- ✅ **Iterator methods** - Easy iteration through pages and individual results +- ✅ **Routing metadata** - Automatically adds ALB routing headers +- ✅ **Custom HTTP adapter** - Use your own HTTP library (axios, node-fetch, etc.) + +## Installation + +The DataApi class is included with the SDK: + +```javascript +const LearnositySDK = require('learnosity-sdk-nodejs'); +const DataApi = LearnositySDK.DataApi; +``` + +Or import directly: + +```javascript +const DataApi = require('learnosity-sdk-nodejs/lib/DataApi'); +``` + +## Basic Usage + +### Simple Request + +```javascript +const DataApi = require('learnosity-sdk-nodejs/lib/DataApi'); + +const dataApi = new DataApi({ + consumerKey: 'your_consumer_key', + consumerSecret: 'your_consumer_secret', + domain: 'yourdomain.com' +}); + +// Make a request +const response = await dataApi.request( + 'https://data.learnosity.com/v2023.1.LTS/itembank/items', + { + consumer_key: 'your_consumer_key', + domain: 'yourdomain.com' + }, + 'your_consumer_secret', + { + limit: 10, + references: ['item_1', 'item_2'] + }, + 'get' +); + +const data = await response.json(); +console.log(data); +``` + +### Paginated Requests + +Automatically iterate through all pages: + +```javascript +for await (const page of dataApi.requestIter( + 'https://data.learnosity.com/v2023.1.LTS/itembank/items', + { consumer_key: 'xxx', domain: 'example.com' }, + 'secret', + { limit: 100 }, + 'get' +)) { + console.log(`Page has ${page.data.length} items`); + console.log(`Total records: ${page.meta.records}`); +} +``` + +### Individual Results Iterator + +Iterate through individual results across all pages: + +```javascript +for await (const item of dataApi.resultsIter( + 'https://data.learnosity.com/v2023.1.LTS/itembank/items', + { consumer_key: 'xxx', domain: 'example.com' }, + 'secret', + { limit: 100 }, + 'get' +)) { + console.log(`Item: ${item.reference}`); +} +``` + +## API Reference + +### Constructor + +```javascript +new DataApi(options) +``` + +**Options:** +- `consumerKey` (string, optional) - Your Learnosity consumer key +- `consumerSecret` (string, optional) - Your Learnosity consumer secret +- `domain` (string, optional) - Your domain for security packet +- `httpAdapter` (function, optional) - Custom HTTP adapter function + +### Methods + +#### `request(endpoint, securityPacket, secret, requestPacket, action)` + +Makes a single HTTP request to the Data API. + +**Parameters:** +- `endpoint` (string) - Full URL to the Data API endpoint +- `securityPacket` (object) - Security object with `consumer_key` and `domain` +- `secret` (string) - Consumer secret for signing +- `requestPacket` (object, optional) - Request parameters +- `action` (string, optional) - Action type: `'get'`, `'set'`, `'update'`, `'delete'` (default: `'get'`) + +**Returns:** Promise + +#### `requestIter(endpoint, securityPacket, secret, requestPacket, action)` + +Async generator that yields pages of results, automatically handling pagination. + +**Parameters:** Same as `request()` + +**Yields:** Page objects with `meta` and `data` properties + +#### `resultsIter(endpoint, securityPacket, secret, requestPacket, action)` + +Async generator that yields individual results from the `data` array, automatically handling pagination. + +**Parameters:** Same as `request()` + +**Yields:** Individual result objects + +## Advanced Usage + +### Custom HTTP Adapter + +Use your own HTTP library (e.g., axios): + +```javascript +const axios = require('axios'); + +const dataApi = new DataApi({ + consumerKey: 'xxx', + consumerSecret: 'secret', + domain: 'example.com', + httpAdapter: async (url, options) => { + const response = await axios({ + method: options.method, + url: url, + headers: options.headers, + data: options.body + }); + + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + statusText: response.statusText, + json: async () => response.data, + text: async () => JSON.stringify(response.data) + }; + } +}); +``` + +## Routing Metadata + +The DataApi automatically adds routing metadata headers to all requests: + +- `X-Learnosity-Consumer` - Consumer key +- `X-Learnosity-Action` - Derived action (e.g., `get_/itembank/items`) +- `X-Learnosity-SDK` - SDK version (e.g., `Node.js:0.6.2`) + +These headers are used by Learnosity's Application Load Balancer for routing. + +## Examples + +See the [examples/data-api-example.js](../examples/data-api-example.js) file for complete working examples. + +## Comparison with Python SDK + +This implementation mirrors the Python SDK's DataApi class, providing: + +- `request()` - Single request (Python: `request()`) +- `requestIter()` - Page iterator (Python: `request_iter()`) +- `resultsIter()` - Results iterator (Python: `results_iter()`) + diff --git a/docs/quickstart/index.js b/docs/quickstart/index.js new file mode 100644 index 0000000..f521bc4 --- /dev/null +++ b/docs/quickstart/index.js @@ -0,0 +1,313 @@ +// Copyright (c) 2024 Learnosity, Apache 2.0 License +// +// Unified quickstart server with all API examples +'use strict'; + +const Learnosity = require('../../index'); +const DataApi = require('../../lib/DataApi'); +const config = require('./config'); +const uuid = require('uuid'); +const express = require('express'); +const packageJson = require('../../package.json'); +const app = express(); +const port = 8000; +const domain = 'localhost'; + +app.set('view engine', 'ejs'); + +// Serve static CSS files +app.use('/css', express.static('./css')); + +// Home page - list all examples +app.get('/', function (req, res) { + res.render('index', { + name: 'Learnosity SDK Quickstart Examples' + }); +}); + +// Items API - Standalone Assessment +app.get('/itemsapi', function (req, res) { + const learnositySdk = new Learnosity(); + const user_id = uuid.v4(); + const session_id = uuid.v4(); + + const request = learnositySdk.init( + 'items', + { + consumer_key: config.consumerKey, + domain: domain + }, + config.consumerSecret, + { + user_id: user_id, + activity_template_id: 'quickstart_examples_activity_template_001', + session_id: session_id, + activity_id: 'quickstart_examples_activity_001', + rendering_type: 'assess', + type: 'submit_practice', + name: 'Items API Quickstart', + state: 'initial' + } + ); + + res.render('standalone-assessment', { request }); +}); + +// Questions API +app.get('/questionsapi', function (req, res) { + const learnositySdk = new Learnosity(); + + const request = learnositySdk.init( + 'questions', + { + consumer_key: config.consumerKey, + domain: domain + }, + config.consumerSecret, + { + type: 'local_practice', + state: 'initial', + questions: [ + { + response_id: '60005', + type: 'association', + stimulus: 'Match the cities to the parent nation.', + stimulus_list: ['London', 'Dublin', 'Paris', 'Sydney'], + possible_responses: ['Australia', 'France', 'Ireland', 'England'], + validation: { + valid_responses: [ + ['England'], ['Ireland'], ['France'], ['Australia'] + ] + } + } + ] + } + ); + + res.render('questions', { request }); +}); + +// Author API - Item List +app.get('/authorapi', function (req, res) { + const learnositySdk = new Learnosity(); + + const request = learnositySdk.init( + 'author', + { + consumer_key: config.consumerKey, + domain: domain + }, + config.consumerSecret, + { + mode: 'item_list', + config: { + item_list: { + item: { + status: true + } + } + }, + user: { + id: 'demos-site', + firstname: 'Demos', + lastname: 'User', + email: 'demos@learnosity.com' + } + } + ); + + res.render('item-list', { request }); +}); + +// Reports API +app.get('/reportsapi', function (req, res) { + const learnositySdk = new Learnosity(); + + const request = learnositySdk.init( + 'reports', + { + consumer_key: config.consumerKey, + domain: domain + }, + config.consumerSecret, + { + reports: [ + { + id: 'report-1', + type: 'sessions-summary', + user_id: '$ANONYMIZED_USER_ID' + } + ] + } + ); + + res.render('reports', { request }); +}); + +// Author Aide API +app.get('/authoraide', function (req, res) { + const learnositySdk = new Learnosity(); + + const request = learnositySdk.init( + 'authoraide', + { + consumer_key: config.consumerKey, + domain: domain + }, + config.consumerSecret, + { + user: { + id: 'demos-site', + firstname: 'Demos', + lastname: 'User', + email: 'demos@learnosity.com' + } + } + ); + + res.render('main', { request }); +}); + +// Data API +app.get('/dataapi', async function (req, res) { + const itembank_uri = 'https://data.learnosity.com/latest-lts/itembank/items'; + const security_packet = { + consumer_key: config.consumerKey, + domain: domain + }; + + const dataApi = new DataApi({ + consumerKey: config.consumerKey, + consumerSecret: config.consumerSecret, + domain: domain + }); + + // Initialize request metadata (will be populated from first successful request) + const sdk_version = packageJson.version; + const request_metadata = { + endpoint: itembank_uri, + action: 'get', + statusCode: null, + headers: { + 'X-Learnosity-Consumer': dataApi._extractConsumer(security_packet), + 'X-Learnosity-Action': dataApi._deriveAction(itembank_uri, 'get'), + 'X-Learnosity-SDK': `Node.js:${sdk_version}` + } + }; + + // Demo 1: Manual iteration (5 items) + const demo1_output = []; + let demo1_error = null; + + try { + let data_request = { limit: 1 }; + + for (let i = 0; i < 5; i++) { + const result = await dataApi.request(itembank_uri, security_packet, + config.consumerSecret, data_request, 'get'); + + // Capture status code from the first request + if (i === 0 && result) { + request_metadata.statusCode = result.status; + } + + const response = await result.json(); + + if (response.data && response.data.length > 0) { + const item = response.data[0]; + + demo1_output.push({ + number: i + 1, + reference: item.reference || 'N/A', + status: item.status || 'N/A' + }); + } + + if (response.meta && response.meta.next) { + data_request = { next: response.meta.next }; + } else { + break; + } + } + } catch (error) { + demo1_error = error.message; + } + + // Demo 2: Page iteration (5 pages) + const demo2_output = []; + let demo2_error = null; + + try { + const data_request = { limit: 1 }; + let page_count = 0; + + for await (const page of dataApi.requestIter(itembank_uri, security_packet, + config.consumerSecret, data_request, 'get')) { + page_count++; + const pageData = { + pageNumber: page_count, + itemCount: page.data ? page.data.length : 0, + items: [] + }; + + if (page.data) { + for (const item of page.data) { + pageData.items.push({ + reference: item.reference || 'N/A', + status: item.status || 'N/A' + }); + } + } + + demo2_output.push(pageData); + + if (page_count >= 5) { + break; + } + } + } catch (error) { + demo2_error = error.message; + } + + // Demo 3: Results iteration (5 items) + const demo3_output = []; + let demo3_error = null; + + try { + const data_request = { limit: 1 }; + let result_count = 0; + + for await (const item of dataApi.resultsIter(itembank_uri, security_packet, + config.consumerSecret, data_request, 'get')) { + result_count++; + demo3_output.push({ + number: result_count, + reference: item.reference || 'N/A', + status: item.status || 'N/A', + json: JSON.stringify(item, null, 2).substring(0, 500) + }); + + if (result_count >= 5) { + break; + } + } + } catch (error) { + demo3_error = error.message; + } + + res.render('data-api', { + name: 'Data API Example - With Metadata Headers', + request_metadata: request_metadata, + demo1_output: demo1_output, + demo1_error: demo1_error, + demo2_output: demo2_output, + demo2_error: demo2_error, + demo3_output: demo3_output, + demo3_error: demo3_error + }); +}); + +app.listen(port, function () { + console.log(`Server started http://${domain}:${port}. Press Ctrl-c to quit.`); +}); + diff --git a/docs/quickstart/package.json b/docs/quickstart/package.json index 6ecbd9b..15482fe 100644 --- a/docs/quickstart/package.json +++ b/docs/quickstart/package.json @@ -10,11 +10,7 @@ }, "devDependencies": {}, "scripts": { - "start-standalone-assessment": "npm install && node ./assessment/standalone-assessment.js", - "start-reports": "npm install && node ./analytics/reports.js", - "start-item-list": "npm install && node ./authoring/item-list.js", - "start-questions": "npm install && node ./questions/questions.js", - "start-authoraide": "npm install && node ./authoraide/main.js" + "start": "npm install && node ./index.js" }, "author": "", "license": "ISC" diff --git a/docs/quickstart/views/data-api.ejs b/docs/quickstart/views/data-api.ejs new file mode 100644 index 0000000..ff1309b --- /dev/null +++ b/docs/quickstart/views/data-api.ejs @@ -0,0 +1,266 @@ + + + + + + + +

<%= name %>

+ +
+ Note: This example demonstrates the Data API with automatic metadata headers. Every request includes consumer, action, and SDK language-version information. +
+ + <% if (request_metadata) { %> +
+

API Responses

+ +
Request Information
+
+
+
Endpoint
+
<%= request_metadata.endpoint %>
+
+
+
Action
+
<%= request_metadata.action %>
+
+
+
Status Code
+
<%= request_metadata.statusCode || 'N/A' %>
+
+
+ + + +
+
+
X-Learnosity-Consumer
+
<%= request_metadata.headers['X-Learnosity-Consumer'] %>
+
+
+
X-Learnosity-Action
+
<%= request_metadata.headers['X-Learnosity-Action'] %>
+
+
+
X-Learnosity-SDK
+
<%= request_metadata.headers['X-Learnosity-SDK'] %>
+
+
+
+ <% } %> + +
+

Demo 1: Manual Iteration (5 items)

+

Using request() method with manual pagination via the 'next' pointer.

+ <% if (demo1_error) { %> +
Error: <%= demo1_error %>
+ <% } else { %> + <% demo1_output.forEach(function(item) { %> +
+
Item <%= item.number %>: <%= item.reference %>
+
Status: <%= item.status %>
+
+ <% }); %> + <% } %> +
+ +
+

Demo 2: Page Iteration (5 pages)

+

Using requestIter() method to automatically iterate over pages.

+ <% if (demo2_error) { %> +
Error: <%= demo2_error %>
+ <% } else { %> + <% demo2_output.forEach(function(page) { %> +
+ Page <%= page.pageNumber %>: <%= page.itemCount %> items +
+ <% page.items.forEach(function(item) { %> +
+
<%= item.reference %>
+
Status: <%= item.status %>
+
+ <% }); %> + <% }); %> + <% } %> +
+ +
+

Demo 3: Results Iteration (5 items)

+

Using resultsIter() method to automatically iterate over individual items.

+ <% if (demo3_error) { %> +
Error: <%= demo3_error %>
+ <% } else { %> + <% demo3_output.forEach(function(item) { %> +
+
Item <%= item.number %>: <%= item.reference %>
+
Status: <%= item.status %>
+
<%= item.json %>...
+
+ <% }); %> + <% } %> +
+ +

Back to API Examples

+ + + diff --git a/docs/quickstart/views/index.ejs b/docs/quickstart/views/index.ejs new file mode 100644 index 0000000..cb2119d --- /dev/null +++ b/docs/quickstart/views/index.ejs @@ -0,0 +1,125 @@ + + + + + + + +
+

<%= name %>

+ +
+

📚 Available Examples

+

Click on any link below to view a live example of each Learnosity API.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
APIDescriptionLink
Items APIStandalone assessment exampleView Demo
Questions APIInline questions exampleView Demo
Author APIItem list authoring exampleView Demo
Reports APIAnalytics and reporting exampleView Demo
Author Aide APIAI-powered authoring assistantView Demo
Data APIData retrieval and pagination exampleView Demo
+ +
+

🔧 Configuration

+

Make sure to update docs/quickstart/config.js with your Learnosity consumer key and secret before running the examples.

+
+
+ + + diff --git a/index.d.ts b/index.d.ts index ddf23e5..b31f192 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,3 +1,5 @@ +import DataApiClass = require('./lib/DataApi'); + export = LearnositySDK; /** * @constructor @@ -16,6 +18,8 @@ declare class LearnositySDK { * @returns object The init options for a Learnosity API */ init(service: Service, securityPacket: SecurityPacket, secret: string, requestPacket: RequestPacket, action?: Action): any; + + static DataApi: typeof DataApiClass; } declare namespace LearnositySDK { export { enableTelemetry, disableTelemetry, SecurityPacket, SDKMeta, RequestMeta, RequestPacket, Service, Action }; diff --git a/index.js b/index.js index ff59433..ca88ba3 100644 --- a/index.js +++ b/index.js @@ -283,4 +283,7 @@ LearnositySDK.prototype.init = function ( return output; }; +// Export DataApi class as a property +LearnositySDK.DataApi = require('./lib/DataApi'); + module.exports = LearnositySDK; diff --git a/lib/DataApi.js b/lib/DataApi.js new file mode 100644 index 0000000..5ddc395 --- /dev/null +++ b/lib/DataApi.js @@ -0,0 +1,208 @@ +'use strict'; + +/** + * DataApi - Routing layer for Learnosity Data API + * + * Provides methods to make HTTP requests to the Data API with automatic + * signing and pagination support. + */ + +// Lazy load to avoid circular dependency +let LearnositySDK = null; + +class DataApi { + /** + * @param {Object} options - Configuration options + * @param {string} options.consumerKey - Learnosity consumer key + * @param {string} options.consumerSecret - Learnosity consumer secret + * @param {string} options.domain - Domain for security packet + * @param {Function} [options.httpAdapter] - Optional custom HTTP adapter + */ + constructor(options = {}) { + this.consumerKey = options.consumerKey; + this.consumerSecret = options.consumerSecret; + this.domain = options.domain; + this.httpAdapter = options.httpAdapter || this._defaultHttpAdapter.bind(this); + + // Lazy load SDK to avoid circular dependency + if (!LearnositySDK) { + LearnositySDK = require('../index'); + } + this.sdk = new LearnositySDK(); + } + + /** + * Default HTTP adapter using native fetch + * @private + */ + async _defaultHttpAdapter(url, options) { + const response = await fetch(url, options); + + return ({ + ok: response.ok, + status: response.status, + statusText: response.statusText, + json: async () => response.json(), + text: async () => response.text() + }); + } + + /** + * Extract consumer key from security packet + * @private + */ + _extractConsumer(securityPacket) { + return securityPacket.consumer_key || ''; + } + + /** + * Derive action metadata from endpoint and action + * @private + */ + _deriveAction(endpoint, action) { + const url = new URL(endpoint); + let path = url.pathname.replace(/\/$/, ''); + + // Remove version prefix (e.g., /v1, /v2023.1.LTS, /latest) + const pathParts = path.split('/'); + + if (pathParts.length > 1) { + const firstSegment = pathParts[1].toLowerCase(); + const versionPattern = /^v[\d.]+(?:\.(lts|preview\d+))?$/; + const specialVersions = ['latest', 'latest-lts', 'developer']; + + if (versionPattern.test(firstSegment) || specialVersions.includes(firstSegment)) { + path = '/' + pathParts.slice(2).join('/'); + } + } + + return `${action}_${path}`; + } + + /** + * Make a single request to Data API + * + * @param {string} endpoint - Full URL to the Data API endpoint + * @param {Object} securityPacket - Security object with consumer_key and domain + * @param {string} secret - Consumer secret + * @param {Object} [requestPacket={}] - Request parameters + * @param {string} [action='get'] - Action type: 'get', 'set', 'update', 'delete' + * @returns {Promise} Response object with status, data, etc. + */ + async request(endpoint, securityPacket, secret, requestPacket = {}, action = 'get') { + // Generate signed request using SDK + const signedRequest = this.sdk.init('data', securityPacket, secret, requestPacket, action); + + // Extract metadata for routing + const consumer = this._extractConsumer(securityPacket); + const derivedAction = this._deriveAction(endpoint, action); + + // Get SDK version from package.json + const packageInfo = require('../package.json'); + const sdkVersion = packageInfo.version; + + // Prepare headers with routing metadata + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Learnosity-Consumer': consumer, + 'X-Learnosity-Action': derivedAction, + 'X-Learnosity-SDK': `Node.js:${sdkVersion}` + }; + + // Convert signed request to URL-encoded format + const formBody = new URLSearchParams({ + security: signedRequest.security, + request: signedRequest.request, + action: signedRequest.action + }).toString(); + + // Make HTTP request + const response = await this.httpAdapter(endpoint, { + method: 'POST', + headers: headers, + body: formBody + }); + + return response; + } + + /** + * Iterate over pages of results from Data API + * + * @param {string} endpoint - Full URL to the Data API endpoint + * @param {Object} securityPacket - Security object + * @param {string} secret - Consumer secret + * @param {Object} [requestPacket={}] - Request parameters + * @param {string} [action='get'] - Action type + * @returns {AsyncGenerator} Async generator yielding pages of results + */ + async *requestIter(endpoint, securityPacket, secret, requestPacket = {}, action = 'get') { + // Deep copy to avoid mutation + const security = JSON.parse(JSON.stringify(securityPacket)); + const request = JSON.parse(JSON.stringify(requestPacket)); + let dataEnd = false; + + while (!dataEnd) { + const response = await this.request(endpoint, security, secret, request, action); + + if (!response.ok) { + const text = await response.text(); + + throw new Error(`Server returned HTTP status ${response.status}: ${text}`); + } + + let data; + + try { + data = await response.json(); + } catch (error) { + const text = await response.text(); + + throw new Error(`Server returned invalid JSON: ${text}`); + } + + if (data.meta && data.meta.next && data.data && data.data.length > 0) { + request.next = data.meta.next; + } else { + dataEnd = true; + } + + if (!data.meta || !data.meta.status) { + throw new Error(`Server returned unsuccessful status: ${JSON.stringify(data)}`); + } + + yield data; + } + } + + /** + * Iterate over individual results from Data API + * + * Automatically handles pagination and yields each individual result + * from the data array. + * + * @param {string} endpoint - Full URL to the Data API endpoint + * @param {Object} securityPacket - Security object + * @param {string} secret - Consumer secret + * @param {Object} [requestPacket={}] - Request parameters + * @param {string} [action='get'] - Action type + * @returns {AsyncGenerator} Async generator yielding individual results + */ + async *resultsIter(endpoint, securityPacket, secret, requestPacket = {}, action = 'get') { + for await (const page of this.requestIter(endpoint, securityPacket, secret, requestPacket, action)) { + if (typeof page.data === 'object' && !Array.isArray(page.data)) { + // If data is an object (not array), yield key-value pairs + for (const [key, value] of Object.entries(page.data)) { + yield({ [key]: value }); + } + } else if (Array.isArray(page.data)) { + // If data is an array, yield each item + for (const result of page.data) { + yield result; + } + } + } + } +} + +module.exports = DataApi; diff --git a/test/dataapi.spec.js b/test/dataapi.spec.js new file mode 100644 index 0000000..12a05e0 --- /dev/null +++ b/test/dataapi.spec.js @@ -0,0 +1,178 @@ +'use strict'; + +const assert = require('assert'); +const DataApi = require('../lib/DataApi'); + +describe('DataApi', function () { + const config = { + consumerKey: 'yis0TYCu7U9V4o7M', + consumerSecret: '74c5fd430cf1242a527f6223aebd42d30464be22', + domain: 'localhost' + }; + + const securityPacket = { + consumer_key: config.consumerKey, + domain: config.domain + }; + + describe('constructor', function () { + it('should create instance with options', function () { + const dataApi = new DataApi(config); + + assert.strictEqual(dataApi.consumerKey, config.consumerKey); + assert.strictEqual(dataApi.consumerSecret, config.consumerSecret); + assert.strictEqual(dataApi.domain, config.domain); + }); + + it('should create instance without options', function () { + const dataApi = new DataApi(); + + assert.ok(dataApi); + }); + }); + + describe('_extractConsumer', function () { + it('should extract consumer key from security packet', function () { + const dataApi = new DataApi(config); + const consumer = dataApi._extractConsumer(securityPacket); + + assert.strictEqual(consumer, config.consumerKey); + }); + + it('should return empty string if no consumer key', function () { + const dataApi = new DataApi(config); + const consumer = dataApi._extractConsumer({}); + + assert.strictEqual(consumer, ''); + }); + }); + + describe('_deriveAction', function () { + it('should derive action from endpoint with version', function () { + const dataApi = new DataApi(config); + const action = dataApi._deriveAction( + 'https://data.learnosity.com/v2023.1.LTS/itembank/items', + 'get' + ); + + assert.strictEqual(action, 'get_/itembank/items'); + }); + + it('should derive action from endpoint with latest', function () { + const dataApi = new DataApi(config); + const action = dataApi._deriveAction( + 'https://data.learnosity.com/latest/itembank/items', + 'get' + ); + + assert.strictEqual(action, 'get_/itembank/items'); + }); + + it('should derive action from endpoint without version', function () { + const dataApi = new DataApi(config); + const action = dataApi._deriveAction( + 'https://data.learnosity.com/itembank/items', + 'get' + ); + + assert.strictEqual(action, 'get_/itembank/items'); + }); + + it('should handle trailing slash', function () { + const dataApi = new DataApi(config); + const action = dataApi._deriveAction( + 'https://data.learnosity.com/v1/itembank/items/', + 'get' + ); + + assert.strictEqual(action, 'get_/itembank/items'); + }); + }); + + describe('request', function () { + it('should make a request with mock adapter', async function () { + const mockResponse = { + meta: { status: true, records: 1 }, + data: [{ reference: 'item_1' }] + }; + + const mockAdapter = async (url, options) => { + assert.strictEqual(url, 'https://data.learnosity.com/v1/itembank/items'); + assert.strictEqual(options.method, 'POST'); + assert.ok(options.headers['X-Learnosity-Consumer']); + assert.ok(options.headers['X-Learnosity-Action']); + assert.ok(options.headers['X-Learnosity-SDK']); + assert.ok(options.body.includes('security=')); + + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => mockResponse, + text: async () => JSON.stringify(mockResponse) + }; + }; + + const dataApi = new DataApi({ ...config, httpAdapter: mockAdapter }); + const response = await dataApi.request( + 'https://data.learnosity.com/v1/itembank/items', + securityPacket, + config.consumerSecret, + { limit: 1 }, + 'get' + ); + + assert.ok(response.ok); + assert.strictEqual(response.status, 200); + const data = await response.json(); + + assert.deepStrictEqual(data, mockResponse); + }); + }); + + describe('requestIter', function () { + it('should iterate through pages', async function () { + const mockResponses = [ + { + meta: { status: true, records: 2, next: 'page2' }, + data: [{ id: 'a' }] + }, + { + meta: { status: true, records: 2 }, + data: [{ id: 'b' }] + } + ]; + + let callCount = 0; + const mockAdapter = async () => { + const response = mockResponses[callCount++]; + + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => response, + text: async () => JSON.stringify(response) + }; + }; + + const dataApi = new DataApi({ ...config, httpAdapter: mockAdapter }); + const pages = []; + + for await (const page of dataApi.requestIter( + 'https://data.learnosity.com/v1/itembank/items', + securityPacket, + config.consumerSecret, + {}, + 'get' + )) { + pages.push(page); + } + + assert.strictEqual(pages.length, 2); + assert.strictEqual(pages[0].data[0].id, 'a'); + assert.strictEqual(pages[1].data[0].id, 'b'); + }); + }); +}); + diff --git a/test/integration-dataapi.spec.js b/test/integration-dataapi.spec.js new file mode 100644 index 0000000..7374036 --- /dev/null +++ b/test/integration-dataapi.spec.js @@ -0,0 +1,174 @@ +'use strict'; + +/** + * Integration tests for DataApi + * + * These tests verify that the DataApi class correctly: + * - Signs requests + * - Adds routing metadata headers + * - Formats requests properly + * + * Note: These tests use a mock HTTP adapter and do not make real API calls. + */ + +const assert = require('assert'); +const DataApi = require('../lib/DataApi'); + +describe('DataApi Integration Tests', function () { + const config = { + consumerKey: 'yis0TYCu7U9V4o7M', + consumerSecret: '74c5fd430cf1242a527f6223aebd42d30464be22', + domain: 'localhost' + }; + + describe('Request signing and formatting', function () { + it('should properly sign and format a Data API request', async function () { + let capturedRequest = null; + + const mockAdapter = async (url, options) => { + capturedRequest = { + url, + method: options.method, + headers: options.headers, + body: options.body + }; + + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + meta: { status: true, records: 0 }, + data: [] + }), + text: async () => JSON.stringify({ + meta: { status: true, records: 0 }, + data: [] + }) + }; + }; + + const dataApi = new DataApi({ ...config, httpAdapter: mockAdapter }); + + await dataApi.request( + 'https://data.learnosity.com/v2023.1.LTS/itembank/items', + { + consumer_key: config.consumerKey, + domain: config.domain + }, + config.consumerSecret, + { + limit: 5, + references: ['item_1', 'item_2'] + }, + 'get' + ); + + // Verify request was captured + assert.ok(capturedRequest, 'Request should be captured'); + + // Verify URL + assert.strictEqual( + capturedRequest.url, + 'https://data.learnosity.com/v2023.1.LTS/itembank/items' + ); + + // Verify method + assert.strictEqual(capturedRequest.method, 'POST'); + + // Verify headers + assert.strictEqual( + capturedRequest.headers['Content-Type'], + 'application/x-www-form-urlencoded' + ); + assert.strictEqual( + capturedRequest.headers['X-Learnosity-Consumer'], + config.consumerKey + ); + assert.strictEqual( + capturedRequest.headers['X-Learnosity-Action'], + 'get_/itembank/items' + ); + assert.ok( + capturedRequest.headers['X-Learnosity-SDK'].startsWith('Node.js:') + ); + + // Verify body contains required fields + assert.ok(capturedRequest.body.includes('security=')); + assert.ok(capturedRequest.body.includes('request=')); + assert.ok(capturedRequest.body.includes('action=')); + assert.ok(capturedRequest.body.includes('signature')); + }); + + it('should handle different API versions in endpoint', async function () { + const testCases = [ + { + endpoint: 'https://data.learnosity.com/v1/itembank/items', + expectedAction: 'get_/itembank/items' + }, + { + endpoint: 'https://data.learnosity.com/v2023.1.LTS/itembank/items', + expectedAction: 'get_/itembank/items' + }, + { + endpoint: 'https://data.learnosity.com/latest/itembank/items', + expectedAction: 'get_/itembank/items' + }, + { + endpoint: 'https://data.learnosity.com/latest-lts/itembank/items', + expectedAction: 'get_/itembank/items' + } + ]; + + for (const testCase of testCases) { + let capturedAction = null; + + const mockAdapter = async (url, options) => { + capturedAction = options.headers['X-Learnosity-Action']; + return ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ meta: { status: true }, data: [] }), + text: async () => '{}' + }); + }; + + const dataApi = new DataApi({ ...config, httpAdapter: mockAdapter }); + + await dataApi.request( + testCase.endpoint, + { consumer_key: config.consumerKey, domain: config.domain }, + config.consumerSecret, + {}, + 'get' + ); + + assert.strictEqual( + capturedAction, + testCase.expectedAction, + `Failed for endpoint: ${testCase.endpoint}` + ); + } + }); + }); + + describe('Export from main module', function () { + it('should be accessible via LearnositySDK.DataApi', function () { + const LearnositySDK = require('../index'); + + assert.ok(LearnositySDK.DataApi, 'DataApi should be exported'); + assert.strictEqual(typeof LearnositySDK.DataApi, 'function'); + + // Should be instantiable + const dataApi = new LearnositySDK.DataApi({ + consumerKey: 'test', + consumerSecret: 'test', + domain: 'test.com' + }); + + assert.ok(dataApi instanceof LearnositySDK.DataApi); + }); + }); +}); +