diff --git a/ChangeLog.md b/ChangeLog.md index 77042d1..1c56b2f 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -7,8 +7,23 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Data API support with dedicated `DataApi` class + - `request()` method for single authenticated Data API requests + - `request_iter()` method for iterating through paginated responses + - `results_iter()` method for iterating through individual results across pages + - Automatic routing metadata headers: `X-Learnosity-Consumer`, `X-Learnosity-Action`, `X-Learnosity-SDK` +- Data API demo added to Rails quickstart application +- Comprehensive unit and integration tests for Data API functionality +- Example usage in `examples/simple/data_api_example.rb` + +### Fixed + +- Ruby 2.6 compatibility in Rails quickstart (commented out `spring` gems that require Ruby 2.7+) +- Rails 6.1 compatibility with Ruby 2.6 (added `require 'logger'` to `config/boot.rb`) - Bumped 3rd party libraries to fix known vulnerabilities in the quick start application -- Fixed seed data for the api-reports example in the quick start application +- Fixed seed data for the api-reports example in the quick start application ## [v0.3.0] - 2024-07-12 ### Added diff --git a/README.md b/README.md index 0a65e7d..78f6cd4 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,9 @@ For production use, you should install the SDK using the RubyGems package manage Let's take a look at a simple example of the SDK in action. In this example, we'll load an assessment into the browser. ### **Start up your web server and view the standalone assessment example** + +**Note:** The Rails quickstart supports Ruby 2.6+. The `spring` and `spring-watcher-listen` gems are commented out in the Gemfile for Ruby 2.6 compatibility. If you're using Ruby 2.7+, you can uncomment these gems for faster development reloading. + To start up your Ruby web server, first find the following folder location under the SDK. Change directory ('cd') to this location on the command line. ``` bash @@ -107,6 +110,7 @@ To start up your Ruby web server, first find the following folder location under To start, run this command from that folder: ``` bash + bundle install rails server ``` @@ -277,6 +281,9 @@ Take a look at some more in-depth options and tutorials on using Learnosity asse ### **SDK reference** See a more detailed breakdown of all the SDK features, and examples of how to use more advanced or specialised features on the [SDK reference page](REFERENCE.md). +### **Data API support** +The SDK now includes comprehensive Data API support with automatic request signing, routing metadata headers, and pagination support. See the [Data API documentation](docs/DataApi.md) for detailed usage examples and API reference. + ### **Additional quick start guides** There are more quick start guides, going beyond the initial quick start topic of loading an assessment, these further tutorials show how to set up authoring and analytics: diff --git a/REFERENCE.md b/REFERENCE.md index 5b9909c..3e48907 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -106,16 +106,67 @@ request = init.generate Net::HTTP.post_form URI('https://data.learnosity.com/v1/itembank/items'), request ``` -### Recursive Queries +### DataApi Class (Recommended) -tl;dr: not currently implemented +The SDK now includes a dedicated `DataApi` class that provides a more convenient way to interact with the Data API, including automatic pagination support, routing metadata headers, and simplified request handling. + +```ruby +require 'learnosity/sdk/request/data_api' + +# Initialize DataApi +data_api = Learnosity::Sdk::Request::DataApi.new( + consumer_key: 'your_consumer_key', + consumer_secret: 'your_consumer_secret', + domain: 'yourdomain.com' +) + +security_packet = { + 'consumer_key' => 'your_consumer_key', + 'domain' => 'yourdomain.com' +} + +# Make a single request +response = data_api.request( + 'https://data.learnosity.com/v1/itembank/items', + security_packet, + 'your_consumer_secret', + { 'limit' => 10 }, + 'get' +) + +# Iterate through all pages automatically +data_api.request_iter( + 'https://data.learnosity.com/v1/itembank/items', + security_packet, + 'your_consumer_secret', + { 'limit' => 100 }, + 'get' +).each do |page| + puts "Page has #{page['data'].length} items" +end + +# Iterate through individual results +data_api.results_iter( + 'https://data.learnosity.com/v1/itembank/items', + security_packet, + 'your_consumer_secret', + { 'limit' => 100 }, + 'get' +).each do |item| + puts "Item: #{item['reference']}" +end +``` + +See the [Data API documentation](docs/DataApi.md) for more details and examples. + +### Recursive Queries (Legacy Approach) Some requests are paginated to the `limit` passed in the request, or some server-side default. Responses to those requests contain a `next` parameter in their `meta` property, which can be placed in the next request to access another page of data. -For the time being, you can iterate through pages by looping over the +You can iterate through pages by looping over the `Init#new`/`Init#generate`/`Net::HTTP#post_form`, updating the `next` attribute in the request. @@ -129,6 +180,8 @@ end This will `require 'json'` to be able to parse the response. +**Note:** The new `DataApi` class (see above) handles pagination automatically and is the recommended approach. + See `examples/simple/init_data.rb` for an example. ### Generating UUIDs diff --git a/docs/quickstart/lrn-sdk-rails/Gemfile b/docs/quickstart/lrn-sdk-rails/Gemfile index 4a55320..ad5eee0 100644 --- a/docs/quickstart/lrn-sdk-rails/Gemfile +++ b/docs/quickstart/lrn-sdk-rails/Gemfile @@ -44,8 +44,10 @@ group :development do gem 'web-console', '>= 4.2.0' gem 'listen', '~> 3.8' # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring - gem 'spring' - gem 'spring-watcher-listen', '~> 2.1.0' + # Note: spring-watcher-listen requires Ruby >= 2.7.0, so it's commented out for Ruby 2.6 compatibility + # If you're using Ruby 2.7+, you can uncomment these lines for faster development reloading: + # gem 'spring' + # gem 'spring-watcher-listen', '~> 2.1.0' end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem diff --git a/docs/quickstart/lrn-sdk-rails/app/controllers/data_api_controller.rb b/docs/quickstart/lrn-sdk-rails/app/controllers/data_api_controller.rb new file mode 100644 index 0000000..883802e --- /dev/null +++ b/docs/quickstart/lrn-sdk-rails/app/controllers/data_api_controller.rb @@ -0,0 +1,156 @@ +require 'learnosity/sdk/request/data_api' +require 'json' + +class DataApiController < ApplicationController + # rubocop:disable Metrics/CyclomaticComplexity + # Note: This is a demo/quickstart controller that intentionally demonstrates + # three different Data API usage patterns (manual iteration, page iteration, + # and results iteration) with comprehensive error handling for educational purposes. + def index + # Initialize DataApi + data_api = Learnosity::Sdk::Request::DataApi.new( + consumer_key: Rails.configuration.consumer_key, + consumer_secret: Rails.configuration.consumer_secret, + domain: 'localhost' + ) + + # Endpoint and security packet + itembank_uri = 'https://data.learnosity.com/latest-lts/itembank/items' + security_packet = { + 'consumer_key' => Rails.configuration.consumer_key, + 'domain' => 'localhost' + } + + # Get SDK version + sdk_version = Learnosity::Sdk::VERSION + + # Initialize request metadata + @request_metadata = { + endpoint: itembank_uri, + action: 'get', + status_code: nil, + headers: { + 'X-Learnosity-Consumer' => data_api.extract_consumer(security_packet), + 'X-Learnosity-Action' => data_api.derive_action(itembank_uri, 'get'), + 'X-Learnosity-SDK' => "Ruby:#{sdk_version}" + } + } + + # Demo 1: Manual iteration (5 items) + @demo1_output = [] + @demo1_error = nil + + begin + data_request = { 'limit' => 1 } + + 5.times do |i| + result = data_api.request( + itembank_uri, + security_packet, + Rails.configuration.consumer_secret, + data_request, + 'get' + ) + + # Capture status code from the first request + @request_metadata[:status_code] = result.code if i == 0 + + response = JSON.parse(result.body) rescue { 'raw_body' => result.body } + + if response['data'] && response['data'].length > 0 + item = response['data'][0] + @demo1_output << { + number: i + 1, + reference: item['reference'] || 'N/A', + status: item['status'] || 'N/A' + } + end + + if response['meta'] && response['meta']['next'] + data_request = { 'next' => response['meta']['next'] } + else + break + end + end + rescue => e + @demo1_error = { + error: "#{e.class}: #{e.message}", + backtrace: e.backtrace&.first(5) + } + end + + # Demo 2: Page iteration (5 pages) + @demo2_output = [] + @demo2_error = nil + + begin + data_request = { 'limit' => 1 } + page_count = 0 + + data_api.request_iter( + itembank_uri, + security_packet, + Rails.configuration.consumer_secret, + data_request, + 'get' + ).each do |page| + page_count += 1 + page_data = { + page_number: page_count, + item_count: page['data'] ? page['data'].length : 0, + items: [] + } + + if page['data'] + page['data'].each do |item| + page_data[:items] << { + reference: item['reference'] || 'N/A', + status: item['status'] || 'N/A' + } + end + end + + @demo2_output << page_data + break if page_count >= 5 + end + rescue => e + @demo2_error = { + error: "#{e.class}: #{e.message}", + backtrace: e.backtrace&.first(5) + } + end + + # Demo 3: Results iteration (5 items) + @demo3_output = [] + @demo3_error = nil + + begin + data_request = { 'limit' => 1 } + result_count = 0 + + data_api.results_iter( + itembank_uri, + security_packet, + Rails.configuration.consumer_secret, + data_request, + 'get' + ).each do |item| + result_count += 1 + @demo3_output << { + number: result_count, + reference: item['reference'] || 'N/A', + status: item['status'] || 'N/A', + json: JSON.pretty_generate(item)[0..500] + } + break if result_count >= 5 + end + rescue => e + @demo3_error = { + error: "#{e.class}: #{e.message}", + backtrace: e.backtrace&.first(5) + } + end + end + # rubocop:enable Metrics/CyclomaticComplexity +end + diff --git a/docs/quickstart/lrn-sdk-rails/app/views/data_api/index.html.erb b/docs/quickstart/lrn-sdk-rails/app/views/data_api/index.html.erb new file mode 100644 index 0000000..62094cf --- /dev/null +++ b/docs/quickstart/lrn-sdk-rails/app/views/data_api/index.html.erb @@ -0,0 +1,277 @@ + + + + + + +

Data API Example - With Metadata Headers

+ +
+ 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[:status_code] || 'N/A' %>
+
+
+ +
Metadata Headers (Sent Automatically)
+
+ These headers are added automatically by the SDK and are invisible to customers: +
+
+
+
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'] %>
+
+
+
+ <% end %> + +
+

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.each do |item| %> +
+
Item <%= item[:number] %>: <%= item[:reference] %>
+
Status: <%= item[:status] %>
+
+ <% end %> + <% end %> +
+ +
+

Demo 2: Page Iteration (5 pages)

+

Using request_iter() method to automatically iterate over pages.

+ <% if @demo2_error %> +
Error: <%= @demo2_error %>
+ <% else %> + <% @demo2_output.each do |page| %> +
+ Page <%= page[:page_number] %>: <%= page[:item_count] %> items +
+ <% page[:items].each do |item| %> +
+
<%= item[:reference] %>
+
Status: <%= item[:status] %>
+
+ <% end %> + <% end %> + <% end %> +
+ +
+

Demo 3: Results Iteration (5 items)

+

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

+ <% if @demo3_error %> +
Error: <%= @demo3_error %>
+ <% else %> + <% @demo3_output.each do |item| %> +
+
Item <%= item[:number] %>: <%= h item[:reference] %>
+
Status: <%= h item[:status] %>
+
<%= h item[:json] %>...
+
+ <% end %> + <% end %> +
+ +

Back to API Examples

+ + + diff --git a/docs/quickstart/lrn-sdk-rails/app/views/index/index.html.erb b/docs/quickstart/lrn-sdk-rails/app/views/index/index.html.erb index 80fdf8d..36d6ded 100644 --- a/docs/quickstart/lrn-sdk-rails/app/views/index/index.html.erb +++ b/docs/quickstart/lrn-sdk-rails/app/views/index/index.html.erb @@ -33,6 +33,10 @@ Authoraide API <%=link_to("Here", authoraide_index_path, target: '_blank') %> + + Data API + <%=link_to("Here", data_api_index_path, target: '_blank') %> + diff --git a/docs/quickstart/lrn-sdk-rails/config/boot.rb b/docs/quickstart/lrn-sdk-rails/config/boot.rb index 30f5120..b463dc3 100644 --- a/docs/quickstart/lrn-sdk-rails/config/boot.rb +++ b/docs/quickstart/lrn-sdk-rails/config/boot.rb @@ -1,3 +1,7 @@ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) require 'bundler/setup' # Set up gems listed in the Gemfile. + +# Fix for Ruby 2.6 compatibility with Rails 6.1 +# Rails 6.1 expects Logger to be available before ActiveSupport loads +require 'logger' diff --git a/docs/quickstart/lrn-sdk-rails/config/routes.rb b/docs/quickstart/lrn-sdk-rails/config/routes.rb index b347202..21270d8 100644 --- a/docs/quickstart/lrn-sdk-rails/config/routes.rb +++ b/docs/quickstart/lrn-sdk-rails/config/routes.rb @@ -6,6 +6,7 @@ get 'authoraide/index' , as: 'authoraide_index' get 'reports/index', as: 'reports_index' get 'items/index', as: 'items_index' + get 'data_api/index', as: 'data_api_index' # get 'abc' , to: "index#index" # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html diff --git a/examples/simple/data_api_example.rb b/examples/simple/data_api_example.rb new file mode 100644 index 0000000..6b5274d --- /dev/null +++ b/examples/simple/data_api_example.rb @@ -0,0 +1,82 @@ +#!/usr/bin/env ruby +require 'learnosity/sdk/request/data_api' + +# Configuration +# XXX: This is a Learnosity Demos consumer; replace it with your own consumer key +consumer_key = 'yis0TYCu7U9V4o7M' +# XXX: The consumer secret should be in a properly secured credential store, and *NEVER* checked into version control +consumer_secret = '74c5fd430cf1242a527f6223aebd42d30464be22' +domain = 'localhost' + +# Initialize DataApi +data_api = Learnosity::Sdk::DataApi.new( + consumer_key: consumer_key, + consumer_secret: consumer_secret, + domain: domain +) + +# Security packet +security_packet = { + 'consumer_key' => consumer_key, + 'domain' => domain +} + +# Endpoint +endpoint = 'https://data.learnosity.com/v1/itembank/items' + +puts "=== Example 1: Single Request ===" +puts + +# Make a single request +response = data_api.request( + endpoint, + security_packet, + consumer_secret, + { 'limit' => 5 }, + 'get' +) + +puts "Status: #{response.code}" +data = JSON.parse(response.body) +puts "Records: #{data['meta']['records']}" +puts "Items returned: #{data['data'].length}" +puts + +puts "=== Example 2: Iterate Through Pages ===" +puts + +# Iterate through pages (up to 3 pages) +page_count = 0 +data_api.request_iter( + endpoint, + security_packet, + consumer_secret, + { 'limit' => 5 }, + 'get' +).each do |page| + page_count += 1 + puts "Page #{page_count}: #{page['data'].length} items" + break if page_count >= 3 +end +puts + +puts "=== Example 3: Iterate Through Individual Results ===" +puts + +# Iterate through individual results (up to 10 items) +item_count = 0 +data_api.results_iter( + endpoint, + security_packet, + consumer_secret, + { 'limit' => 5 }, + 'get' +).each do |item| + item_count += 1 + puts "Item #{item_count}: #{item['reference'] || item['id'] || 'N/A'}" + break if item_count >= 10 +end +puts + +puts "Done!" + diff --git a/lib/learnosity/sdk.rb b/lib/learnosity/sdk.rb index 354c6ab..8f798b0 100644 --- a/lib/learnosity/sdk.rb +++ b/lib/learnosity/sdk.rb @@ -1,8 +1,10 @@ require "learnosity/sdk/version" +require "learnosity/sdk/request/data_api" module Learnosity module Sdk - # Your code goes here... + # Export DataApi class for convenient access + DataApi = Request::DataApi end end diff --git a/lib/learnosity/sdk/request/data_api.rb b/lib/learnosity/sdk/request/data_api.rb new file mode 100644 index 0000000..25d4e28 --- /dev/null +++ b/lib/learnosity/sdk/request/data_api.rb @@ -0,0 +1,196 @@ +require 'net/http' +require 'uri' +require 'json' +require 'learnosity/sdk/request/init' +require 'learnosity/sdk/version' + +module Learnosity + module Sdk + module Request + # DataApi - Routing layer for Learnosity Data API + # + # Provides methods to make HTTP requests to the Data API with automatic + # signing and pagination support. + class DataApi + attr_reader :consumer_key, :consumer_secret, :domain + + # Initialize a new DataApi instance + # + # @param options [Hash] Configuration options + # @option options [String] :consumer_key Learnosity consumer key + # @option options [String] :consumer_secret Learnosity consumer secret + # @option options [String] :domain Domain for security packet + # @option options [Proc] :http_adapter Optional custom HTTP adapter + def initialize(options = {}) + @consumer_key = options[:consumer_key] + @consumer_secret = options[:consumer_secret] + @domain = options[:domain] + @http_adapter = options[:http_adapter] || method(:default_http_adapter) + end + + # Make a single request to Data API + # + # @param endpoint [String] Full URL to the Data API endpoint + # @param security_packet [Hash] Security object with consumer_key and domain + # @param secret [String] Consumer secret + # @param request_packet [Hash] Request parameters (default: {}) + # @param action [String] Action type: 'get', 'set', 'update', 'delete' (default: 'get') + # @return [Net::HTTPResponse] HTTP response object + def request(endpoint, security_packet, secret, request_packet = {}, action = 'get') + # Generate signed request using SDK + init = Init.new('data', security_packet, secret, request_packet, action) + signed_request = init.generate + + # Extract metadata for routing + consumer = extract_consumer(security_packet) + derived_action = derive_action(endpoint, action) + + # Prepare headers with routing metadata + headers = { + 'Content-Type' => 'application/x-www-form-urlencoded', + 'X-Learnosity-Consumer' => consumer, + 'X-Learnosity-Action' => derived_action, + 'X-Learnosity-SDK' => "Ruby:#{Learnosity::Sdk::VERSION}" + } + + # Make HTTP request using adapter + @http_adapter.call(endpoint, signed_request, headers) + end + + # Iterate over pages of results from Data API + # + # @param endpoint [String] Full URL to the Data API endpoint + # @param security_packet [Hash] Security object + # @param secret [String] Consumer secret + # @param request_packet [Hash] Request parameters (default: {}) + # @param action [String] Action type (default: 'get') + # @return [Enumerator] Enumerator yielding pages of results + def request_iter(endpoint, security_packet, secret, request_packet = {}, action = 'get') + Enumerator.new do |yielder| + # Deep copy to avoid mutation + security = deep_copy(security_packet) + request_params = deep_copy(request_packet) + data_end = false + + until data_end + response = self.request(endpoint, security, secret, request_params, action) + validate_response(response) + + data = parse_response_body(response) + validate_response_status(data) + + data_end = !has_more_pages?(data) + request_params['next'] = data['meta']['next'] if data['meta'] && data['meta']['next'] + + yielder << data + end + end + end + + # Iterate over individual results from Data API + # + # Automatically handles pagination and yields each individual result + # from the data array. + # + # @param endpoint [String] Full URL to the Data API endpoint + # @param security_packet [Hash] Security object + # @param secret [String] Consumer secret + # @param request_packet [Hash] Request parameters (default: {}) + # @param action [String] Action type (default: 'get') + # @return [Enumerator] Enumerator yielding individual results + def results_iter(endpoint, security_packet, secret, request_packet = {}, action = 'get') + Enumerator.new do |yielder| + request_iter(endpoint, security_packet, secret, request_packet, action).each do |page| + if page['data'].is_a?(Hash) + # If data is a hash (not array), yield key-value pairs + page['data'].each do |key, value| + yielder << { key => value } + end + elsif page['data'].is_a?(Array) + # If data is an array, yield each item + page['data'].each do |result| + yielder << result + end + end + end + end + end + + # Extract consumer key from security packet + def extract_consumer(security_packet) + security_packet['consumer_key'] || security_packet[:consumer_key] || '' + end + + # Derive action metadata from endpoint and action + def derive_action(endpoint, action) + uri = URI.parse(endpoint) + path = uri.path.sub(/\/$/, '') + + # Remove version prefix (e.g., /v1, /v2023.1.LTS, /latest) + path_parts = path.split('/') + + if path_parts.length > 1 + first_segment = path_parts[1].downcase + version_pattern = /^v[\d.]+(?:\.(lts|preview\d+))?$/ + special_versions = ['latest', 'latest-lts', 'developer'] + + if version_pattern.match?(first_segment) || special_versions.include?(first_segment) + path = '/' + path_parts[2..-1].join('/') + end + end + + "#{action}_#{path}" + end + + private + + # Default HTTP adapter using Net::HTTP + def default_http_adapter(endpoint, signed_request, headers) + uri = URI.parse(endpoint) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == 'https') + http.open_timeout = 15 # seconds to establish connection + http.read_timeout = 60 # seconds to read response + + request = Net::HTTP::Post.new(uri.request_uri, headers) + request.set_form_data(signed_request) + + http.request(request) + end + + # Deep copy a hash to avoid mutation + # Using JSON serialization instead of Marshal for security + def deep_copy(obj) + JSON.parse(JSON.generate(obj)) + end + + # Validate HTTP response + def validate_response(response) + return if response.is_a?(Net::HTTPSuccess) + raise "Server returned HTTP status #{response.code}: #{response.body}" + end + + # Parse response body as JSON + def parse_response_body(response) + JSON.parse(response.body) + rescue JSON::ParserError + raise "Server returned invalid JSON: #{response.body}" + end + + # Validate response has successful status + def validate_response_status(data) + return if data.dig('meta', 'status') == true + raise "Server returned unsuccessful status: #{data.to_json}" + end + + # Check if there are more pages to fetch + def has_more_pages?(data) + data['meta'] && data['meta']['next'] && data['data'] && !data['data'].empty? + end + end + end + end +end + +# vim: sw=2 + diff --git a/spec/integration/data_api_spec.rb b/spec/integration/data_api_spec.rb new file mode 100644 index 0000000..81a0140 --- /dev/null +++ b/spec/integration/data_api_spec.rb @@ -0,0 +1,144 @@ +require 'spec_helper' +require 'learnosity/sdk/request/data_api' + +RSpec.describe 'DataApi Integration Tests' do + let(:config) do + { + consumer_key: 'yis0TYCu7U9V4o7M', + consumer_secret: '74c5fd430cf1242a527f6223aebd42d30464be22', + domain: 'localhost' + } + end + + describe 'Request signing and formatting' do + it 'properly signs and formats a Data API request' do + captured_request = nil + + mock_adapter = lambda do |url, signed_request, headers| + captured_request = { + url: url, + signed_request: signed_request, + headers: headers + } + + double('response', + is_a?: true, + code: '200', + body: JSON.generate({ + 'meta' => { 'status' => true, 'records' => 0 }, + 'data' => [] + }) + ) + end + + data_api = Learnosity::Sdk::Request::DataApi.new( + config.merge(http_adapter: mock_adapter) + ) + + data_api.request( + 'https://data.learnosity.com/v2023.1.LTS/itembank/items', + { + 'consumer_key' => config[:consumer_key], + 'domain' => config[:domain] + }, + config[:consumer_secret], + { + 'limit' => 5, + 'references' => ['item_1', 'item_2'] + }, + 'get' + ) + + # Verify request was captured + expect(captured_request).not_to be_nil + + # Verify URL + expect(captured_request[:url]).to eq('https://data.learnosity.com/v2023.1.LTS/itembank/items') + + # Verify headers + expect(captured_request[:headers]['Content-Type']).to eq('application/x-www-form-urlencoded') + expect(captured_request[:headers]['X-Learnosity-Consumer']).to eq(config[:consumer_key]) + expect(captured_request[:headers]['X-Learnosity-Action']).to eq('get_/itembank/items') + expect(captured_request[:headers]['X-Learnosity-SDK']).to match(/^Ruby:/) + + # Verify signed request contains required fields + expect(captured_request[:signed_request]['security']).to be_a(String) + expect(captured_request[:signed_request]['request']).to be_a(String) + expect(captured_request[:signed_request]['action']).to eq('get') + + # Verify security contains signature + security = JSON.parse(captured_request[:signed_request]['security']) + expect(security['signature']).to be_a(String) + # '$02$' is the Learnosity signature format prefix (version 2) + expect(security['signature']).to start_with('$02$') + end + + it 'handles different API versions in endpoint' do + test_cases = [ + { + endpoint: 'https://data.learnosity.com/v1/itembank/items', + expected_action: 'get_/itembank/items' + }, + { + endpoint: 'https://data.learnosity.com/v2023.1.LTS/itembank/items', + expected_action: 'get_/itembank/items' + }, + { + endpoint: 'https://data.learnosity.com/latest/itembank/items', + expected_action: 'get_/itembank/items' + }, + { + endpoint: 'https://data.learnosity.com/latest-lts/itembank/items', + expected_action: 'get_/itembank/items' + } + ] + + test_cases.each do |test_case| + captured_action = nil + + mock_adapter = lambda do |_url, _signed_request, headers| + captured_action = headers['X-Learnosity-Action'] + + double('response', + is_a?: true, + code: '200', + body: JSON.generate({ 'meta' => { 'status' => true }, 'data' => [] }) + ) + end + + data_api = Learnosity::Sdk::Request::DataApi.new( + config.merge(http_adapter: mock_adapter) + ) + + data_api.request( + test_case[:endpoint], + { 'consumer_key' => config[:consumer_key], 'domain' => config[:domain] }, + config[:consumer_secret], + {}, + 'get' + ) + + expect(captured_action).to eq(test_case[:expected_action]), + "Failed for endpoint: #{test_case[:endpoint]}" + end + end + end + + describe 'Export from main module' do + it 'is accessible via Learnosity::Sdk::DataApi' do + expect(Learnosity::Sdk::DataApi).to be_a(Class) + + # Should be instantiable + data_api = Learnosity::Sdk::DataApi.new( + consumer_key: 'test', + consumer_secret: 'test', + domain: 'test.com' + ) + + expect(data_api).to be_a(Learnosity::Sdk::DataApi) + end + end +end + +# vim: sw=2 + diff --git a/spec/learnosity/sdk/request/data_api_spec.rb b/spec/learnosity/sdk/request/data_api_spec.rb new file mode 100644 index 0000000..5b8873a --- /dev/null +++ b/spec/learnosity/sdk/request/data_api_spec.rb @@ -0,0 +1,367 @@ +require 'spec_helper' +require 'learnosity/sdk/request/data_api' + +RSpec.describe Learnosity::Sdk::Request::DataApi do + let(:config) do + { + consumer_key: 'yis0TYCu7U9V4o7M', + consumer_secret: '74c5fd430cf1242a527f6223aebd42d30464be22', + domain: 'localhost' + } + end + + let(:security_packet) do + { + 'consumer_key' => config[:consumer_key], + 'domain' => config[:domain] + } + end + + describe '#initialize' do + it 'creates instance with options' do + data_api = described_class.new(config) + + expect(data_api.consumer_key).to eq(config[:consumer_key]) + expect(data_api.consumer_secret).to eq(config[:consumer_secret]) + expect(data_api.domain).to eq(config[:domain]) + end + + it 'creates instance without options' do + data_api = described_class.new + + expect(data_api).to be_a(described_class) + end + end + + describe '#extract_consumer' do + it 'extracts consumer key from security packet with string keys' do + data_api = described_class.new(config) + consumer = data_api.extract_consumer(security_packet) + + expect(consumer).to eq(config[:consumer_key]) + end + + it 'extracts consumer key from security packet with symbol keys' do + data_api = described_class.new(config) + consumer = data_api.extract_consumer({ consumer_key: config[:consumer_key] }) + + expect(consumer).to eq(config[:consumer_key]) + end + + it 'returns empty string if no consumer key' do + data_api = described_class.new(config) + consumer = data_api.extract_consumer({}) + + expect(consumer).to eq('') + end + end + + describe '#derive_action' do + it 'derives action from endpoint with version' do + data_api = described_class.new(config) + action = data_api.derive_action( + 'https://data.learnosity.com/v2023.1.LTS/itembank/items', + 'get' + ) + + expect(action).to eq('get_/itembank/items') + end + + it 'derives action from endpoint with latest' do + data_api = described_class.new(config) + action = data_api.derive_action( + 'https://data.learnosity.com/latest/itembank/items', + 'get' + ) + + expect(action).to eq('get_/itembank/items') + end + + it 'derives action from endpoint without version' do + data_api = described_class.new(config) + action = data_api.derive_action( + 'https://data.learnosity.com/itembank/items', + 'get' + ) + + expect(action).to eq('get_/itembank/items') + end + + it 'handles trailing slash' do + data_api = described_class.new(config) + action = data_api.derive_action( + 'https://data.learnosity.com/v1/itembank/items/', + 'get' + ) + + expect(action).to eq('get_/itembank/items') + end + + it 'handles v1 version' do + data_api = described_class.new(config) + action = data_api.derive_action( + 'https://data.learnosity.com/v1/itembank/items', + 'get' + ) + + expect(action).to eq('get_/itembank/items') + end + + it 'handles latest-lts version' do + data_api = described_class.new(config) + action = data_api.derive_action( + 'https://data.learnosity.com/latest-lts/itembank/items', + 'get' + ) + + expect(action).to eq('get_/itembank/items') + end + + it 'handles developer version' do + data_api = described_class.new(config) + action = data_api.derive_action( + 'https://data.learnosity.com/developer/itembank/items', + 'get' + ) + + expect(action).to eq('get_/itembank/items') + end + end + + describe '#request' do + it 'makes a request with mock adapter' do + mock_response = double('response', + is_a?: true, + code: '200', + body: JSON.generate({ + 'meta' => { 'status' => true, 'records' => 1 }, + 'data' => [{ 'reference' => 'item_1' }] + }) + ) + + mock_adapter = lambda do |url, signed_request, headers| + expect(url).to eq('https://data.learnosity.com/v1/itembank/items') + expect(headers['X-Learnosity-Consumer']).to eq(config[:consumer_key]) + expect(headers['X-Learnosity-Action']).to eq('get_/itembank/items') + expect(headers['X-Learnosity-SDK']).to match(/^Ruby:/) + expect(signed_request['security']).to be_a(String) + expect(signed_request['request']).to be_a(String) + expect(signed_request['action']).to eq('get') + + mock_response + end + + data_api = described_class.new(config.merge(http_adapter: mock_adapter)) + response = data_api.request( + 'https://data.learnosity.com/v1/itembank/items', + security_packet, + config[:consumer_secret], + { 'limit' => 1 }, + 'get' + ) + + expect(response).to eq(mock_response) + expect(response.code).to eq('200') + data = JSON.parse(response.body) + expect(data['meta']['status']).to be true + expect(data['data'].length).to eq(1) + end + end + + describe '#request_iter' do + it 'iterates through pages' do + mock_responses = [ + double('response1', + is_a?: true, + code: '200', + body: JSON.generate({ + 'meta' => { 'status' => true, 'records' => 2, 'next' => 'page2' }, + 'data' => [{ 'id' => 'a' }] + }) + ), + double('response2', + is_a?: true, + code: '200', + body: JSON.generate({ + 'meta' => { 'status' => true, 'records' => 2 }, + 'data' => [{ 'id' => 'b' }] + }) + ) + ] + + call_count = 0 + mock_adapter = lambda do |_url, _signed_request, _headers| + response = mock_responses[call_count] + call_count += 1 + response + end + + data_api = described_class.new(config.merge(http_adapter: mock_adapter)) + pages = [] + + data_api.request_iter( + 'https://data.learnosity.com/v1/itembank/items', + security_packet, + config[:consumer_secret], + {}, + 'get' + ).each do |page| + pages << page + end + + expect(pages.length).to eq(2) + expect(pages[0]['data'][0]['id']).to eq('a') + expect(pages[1]['data'][0]['id']).to eq('b') + end + + it 'raises error on HTTP failure' do + mock_response = double('response', + is_a?: false, + code: '500', + body: 'Internal Server Error' + ) + + mock_adapter = ->(_url, _signed_request, _headers) { mock_response } + + data_api = described_class.new(config.merge(http_adapter: mock_adapter)) + + expect { + data_api.request_iter( + 'https://data.learnosity.com/v1/itembank/items', + security_packet, + config[:consumer_secret], + {}, + 'get' + ).first + }.to raise_error(/Server returned HTTP status 500/) + end + + it 'raises error on invalid JSON' do + mock_response = double('response', + is_a?: true, + code: '200', + body: 'not valid json' + ) + + mock_adapter = ->(_url, _signed_request, _headers) { mock_response } + + data_api = described_class.new(config.merge(http_adapter: mock_adapter)) + + expect { + data_api.request_iter( + 'https://data.learnosity.com/v1/itembank/items', + security_packet, + config[:consumer_secret], + {}, + 'get' + ).first + }.to raise_error(/Server returned invalid JSON/) + end + + it 'raises error on unsuccessful status' do + mock_response = double('response', + is_a?: true, + code: '200', + body: JSON.generate({ + 'meta' => { 'status' => false }, + 'data' => [] + }) + ) + + mock_adapter = ->(_url, _signed_request, _headers) { mock_response } + + data_api = described_class.new(config.merge(http_adapter: mock_adapter)) + + expect { + data_api.request_iter( + 'https://data.learnosity.com/v1/itembank/items', + security_packet, + config[:consumer_secret], + {}, + 'get' + ).first + }.to raise_error(/Server returned unsuccessful status/) + end + end + + describe '#results_iter' do + it 'iterates through individual results from array data' do + mock_responses = [ + double('response1', + is_a?: true, + code: '200', + body: JSON.generate({ + 'meta' => { 'status' => true, 'records' => 3, 'next' => 'page2' }, + 'data' => [{ 'id' => 'a' }, { 'id' => 'b' }] + }) + ), + double('response2', + is_a?: true, + code: '200', + body: JSON.generate({ + 'meta' => { 'status' => true, 'records' => 3 }, + 'data' => [{ 'id' => 'c' }] + }) + ) + ] + + call_count = 0 + mock_adapter = lambda do |_url, _signed_request, _headers| + response = mock_responses[call_count] + call_count += 1 + response + end + + data_api = described_class.new(config.merge(http_adapter: mock_adapter)) + results = [] + + data_api.results_iter( + 'https://data.learnosity.com/v1/itembank/items', + security_packet, + config[:consumer_secret], + {}, + 'get' + ).each do |result| + results << result + end + + expect(results.length).to eq(3) + expect(results[0]['id']).to eq('a') + expect(results[1]['id']).to eq('b') + expect(results[2]['id']).to eq('c') + end + + it 'iterates through individual results from hash data' do + mock_response = double('response', + is_a?: true, + code: '200', + body: JSON.generate({ + 'meta' => { 'status' => true }, + 'data' => { 'key1' => 'value1', 'key2' => 'value2' } + }) + ) + + mock_adapter = ->(_url, _signed_request, _headers) { mock_response } + + data_api = described_class.new(config.merge(http_adapter: mock_adapter)) + results = [] + + data_api.results_iter( + 'https://data.learnosity.com/v1/itembank/items', + security_packet, + config[:consumer_secret], + {}, + 'get' + ).each do |result| + results << result + end + + expect(results.length).to eq(2) + expect(results[0]).to eq({ 'key1' => 'value1' }) + expect(results[1]).to eq({ 'key2' => 'value2' }) + end + end +end + +# vim: sw=2 +