From 9ff378f2124e48f741f30bf173f527db32194edd Mon Sep 17 00:00:00 2001 From: Martijn Slob Date: Tue, 27 Oct 2026 11:43:51 +0100 Subject: [PATCH] v1.1.0 improved API, documentation and tests --- .gitattributes | 8 + .github/workflows/ci.yml | 32 + .github/workflows/release.yml | 27 + CHANGELOG.md | 27 + README.md | 215 +- composer.json | 31 +- demo/play-exercise.php | 241 -- demo/tour.php | 104 + demo/various-functions.php | 133 - openapi-bundled.yaml | 3789 --------------------- phpunit.xml.dist | 13 + src/Exceptions/AlgebrakitApiException.php | 27 + src/Services/SessionService.php | 257 +- tests/SessionServiceTest.php | 150 + 14 files changed, 623 insertions(+), 4431 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md delete mode 100644 demo/play-exercise.php create mode 100644 demo/tour.php delete mode 100644 demo/various-functions.php delete mode 100644 openapi-bundled.yaml create mode 100644 phpunit.xml.dist create mode 100644 src/Exceptions/AlgebrakitApiException.php create mode 100644 tests/SessionServiceTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..be594b0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Leave development files out of the package that Composer downloads +/.github export-ignore +/demo export-ignore +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/CHANGELOG.md export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a7bc23 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: PHP ${{ matrix.php }}${{ matrix.dependencies == 'lowest' && ' (lowest dependencies)' || '' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4'] + dependencies: [highest] + include: + - php: '8.1' + dependencies: lowest + steps: + - uses: actions/checkout@v5 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - run: composer validate --strict + - name: Install dependencies + run: composer update --no-interaction --no-progress ${{ matrix.dependencies == 'lowest' && '--prefer-lowest' || '' }} + - run: composer test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d44e78f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,27 @@ +name: Release + +# Runs when you push a version tag such as v1.1.0. Packagist picks up the tag by itself; +# this workflow only creates the GitHub release, with the notes from CHANGELOG.md. + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Create the GitHub release from CHANGELOG.md + env: + GH_TOKEN: ${{ github.token }} + run: | + notes=$(awk -v heading="## ${GITHUB_REF_NAME#v}" '$0 == heading { found = 1; next } /^## / { found = 0 } found' CHANGELOG.md) + if [ -z "$notes" ]; then + echo "::error::CHANGELOG.md has no section '## ${GITHUB_REF_NAME#v}'" + exit 1 + fi + gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "$notes" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..992d3d1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to `algebrakit/sdk-php` are listed here. The project follows [semantic versioning](https://semver.org). + +## 1.1.0 + +### Fixed +- `lockOrUnlockSession` called `/session/lock`, which doesn't exist, so it always failed with a 404. It now calls `/sessions/lock`. +- The required PHP version is now 8.1. The SDK uses enums, which don't exist in PHP 8.0. + +### Added +- `AlgebrakitApiException`, thrown when a request fails. Its message includes the API's explanation, for example `Algebrakit API error 401: Could not validate credentials: the provided api-key is not valid`, and `getStatusCode()` returns the HTTP status. It extends `RuntimeException`, so existing `catch` blocks keep working. +- A request timeout, 30 seconds by default, set with the new `timeout` constructor argument. +- `SessionService::DEFAULT_BASE_URL`. +- Unit tests and CI. + +### Changed +- Requires Guzzle 7.9 or newer. +- Repository links point to `github.com/algebrakit/sdk-php`. +- The README covers how to show a session in a web page, all API methods, regions and error handling. + +### Removed +- The web demo `demo/play-exercise.php`, replaced by the [minimal PHP example](https://github.com/algebrakit/algebrakit-examples/tree/main/minimal/php) in algebrakit-examples. `demo/various-functions.php` is replaced by `demo/tour.php`, which reads the API key from `ALGEBRAKIT_API_KEY`. + +## 1.0.0 + +- First release: `createSession`, `getSessionScore`, `lockOrUnlockSession`, `getSessionInfo`, `retrieveSessions`, `validateExercise` and `getExerciseInfo`. diff --git a/README.md b/README.md index ddd34e7..99e67b8 100644 --- a/README.md +++ b/README.md @@ -1,167 +1,148 @@ -# Algebrakit PHP SDK +# Algebrakit SDK for PHP -A PHP SDK for interacting with the Algebrakit Web Service API. +[![Packagist](https://img.shields.io/packagist/v/algebrakit/sdk-php)](https://packagist.org/packages/algebrakit/sdk-php) +[![PHP](https://img.shields.io/packagist/dependency-v/algebrakit/sdk-php/php)](https://packagist.org/packages/algebrakit/sdk-php) +[![CI](https://github.com/algebrakit/sdk-php/actions/workflows/ci.yml/badge.svg)](https://github.com/algebrakit/sdk-php/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -## Installation +The official server-side SDK for the [Algebrakit](https://algebrakit.com) API. Your server uses it to create exercise sessions, get scores and manage sessions. Students then work on the exercises in your web page, through the Algebrakit component. -Install the SDK into your project with Composer: +For how Algebrakit works, the full API reference and the component's options, see the [Algebrakit documentation](https://docs.algebrakit.com/). -```bash -composer require algebrakit/sdk-php -``` +> **Want to see it working first?** [algebrakit-examples](https://github.com/algebrakit/algebrakit-examples/tree/main/minimal/php) has a small web app built on this SDK that runs on localhost in a few minutes. -## Requirements +## Before you start -- PHP 8.0 or higher -- Guzzle HTTP client +- PHP 8.1 or newer, and [Composer](https://getcomposer.org). +- An Algebrakit API key. Don't have one yet? [Contact Algebrakit](https://algebrakit.com/contact-us). -## Usage +Keep the API key on your server. Never send it to the browser. -### Basic Setup +## Install -```php -createSession(new CreateSessionRequest( + exercises: [new ExerciseById('fa42e943-8213-41a6-8a91-8c22a929ffe9')] +))[0]; +$sessionId = $result->sessions[0]->sessionId; ``` -### Creating a Session +A session holds one student's work on one exercise. Store its ID with the student, so they can continue later and you can get their score. -```php -// Create a session for an exercise -$createSessionRequest = new CreateSessionRequest( - exercises: [new ExerciseById('your-exercise-id', 'latest')] -); +### Show the session to a student -$createSessionResponse = $sessionService->createSession($createSessionRequest); +In your web page, the Algebrakit component shows the session: -foreach ($createSessionResponse as $response) { - if ($response->success && !empty($response->sessions)) { - $session = $response->sessions[0]; - echo "Session created: " . $session->sessionId . "\n"; - } -} +```html + + + + ``` -### Getting Session Scores +The component sends its API calls to `/proxy/algebrakit/*` on your server. Your server adds the API key and forwards them to the Algebrakit API. The [minimal PHP example](https://github.com/algebrakit/algebrakit-examples/tree/main/minimal/php) implements all of this; for production, see [Proxy setup](https://docs.algebrakit.com/pages/420-proxy-setup/). -```php -use Algebrakit\SDK\Models\Requests\SessionScoreRequest; +## API -$sessionScoreRequest = new SessionScoreRequest('your-session-id'); -$sessionScoreResponse = $sessionService->getSessionScore($sessionScoreRequest); +`SessionService` has one method per API call. Each takes a request object and returns a response object. -foreach ($sessionScoreResponse->questions as $question) { - echo "Question: {$question->id}, Marks: {$question->scoring->marksEarned}/{$question->scoring->marksTotal}\n"; -} -``` +| Method | Endpoint | What it does | +|---|---|---| +| `createSession` | `POST /session/create` | Create sessions for one or more exercises: `ExerciseById`, `ExerciseBySpec` (an exercise defined in code) or `ExerciseBySession` (a copy of an existing session's exercise) | +| `getSessionScore` | `POST /session/score` | Get a session's score, and optionally lock it | +| `getSessionInfo` | `POST /session/info` | Get a session's details: its elements, the student's work and the scoring | +| `lockOrUnlockSession` | `POST /sessions/lock` | `LOCK`, `UNLOCK` or `FINALIZE` sessions | +| `retrieveSessions` | `POST /session/retrieve` | Get existing sessions by ID | +| `validateExercise` | `POST /exercise/validate` | Check an exercise ID or specification before you use it | +| `getExerciseInfo` | `POST /exercise/published-info` | Get the published versions and structure of an exercise | -### Validating an Exercise +### Get the score ```php -use Algebrakit\SDK\Models\Requests\ExerciseValidateRequest; - -$validateRequest = new ExerciseValidateRequest(exerciseId: 'your-exercise-id'); -$validateResponse = $sessionService->validateExercise($validateRequest); +use Algebrakit\SDK\Models\Requests\SessionScoreRequest; -echo "Exercise valid: " . ($validateResponse->valid ? 'Yes' : 'No') . "\n"; +$scoring = $sessionService->getSessionScore(new SessionScoreRequest($sessionId, lockSession: true))->scoring; +echo "{$scoring->marksEarned} of {$scoring->marksTotal} marks\n"; ``` -## Running the Demos - -### CLI Demo - -Demonstrates SDK API calls with text output. - -1. Install dependencies: - ```bash - composer install - ``` - -2. Update the API key in `demo/various-functions.php`: - ```php - $apiKey = 'your-actual-api-key'; - ``` - -3. Run the demo: - ```bash - php demo/various-functions.php - ``` +### Lock a session -### Web Demo - -Renders a working Algebrakit exercise in the browser. +```php +use Algebrakit\SDK\Models\Requests\SessionLockRequest; -1. Install dependencies: - ```bash - composer install - ``` +$sessionService->lockOrUnlockSession(new SessionLockRequest('LOCK', [$sessionId])); +``` -2. Update the configuration in `demo/play-exercise.php`: - ```php - $apiKey = 'your-actual-api-key'; - $apiUrl = 'https://api.algebrakit.com'; - $widgetUrl = 'https://widgets.algebrakit.com'; - ``` +## Configuration -3. Start a local PHP server: - ```bash - php -S localhost:8000 - ``` +Use named arguments for the optional settings: -4. Open `http://localhost:8000/demo/play-exercise.php` in your browser. +```php +$sessionService = new SessionService( + getenv('ALGEBRAKIT_API_KEY'), + baseUrl: 'https://prod.api.sg-1.algebrakit.com', // optional, see the regions below + timeout: 10, // optional, in seconds; the default is 30 +); +``` -## API Reference +| Region | Base URL | +|---|---| +| EU (default) | `https://api.algebrakit.com` | +| Singapore | `https://prod.api.sg-1.algebrakit.com` | -### SessionService Methods +You can also pass your own Guzzle client as the second argument (`httpClient:`), for example to add logging. It must then send the `X-API-KEY` header itself. -- `createSession(CreateSessionRequest $request): array` - Create new exercise sessions -- `getSessionScore(SessionScoreRequest $request): SessionScoreResponse` - Get session scoring results -- `getSessionInfo(SessionInfoRequest $request): SessionInfoResponse` - Get detailed session information -- `retrieveSessions(SessionRetrieveRequest $request): SessionRetrieveResponse` - Retrieve existing sessions -- `lockOrUnlockSession(SessionLockRequest $request): SessionLockResponse` - Lock or unlock sessions -- `validateExercise(ExerciseValidateRequest $request): ExerciseValidateResponse` - Validate an exercise -- `getExerciseInfo(ExerciseInfoRequest $request): ExerciseInfoResponse` - Get exercise information +## Error handling -### Exercise Types +The methods throw an `AlgebrakitApiException` when a request fails. Its message includes the API's explanation, and `getStatusCode()` returns the HTTP status (or `null` for a network error or timeout): -You can specify exercises in three ways: +```php +use Algebrakit\SDK\Exceptions\AlgebrakitApiException; +use Algebrakit\SDK\Models\Requests\SessionInfoRequest; -1. **By Exercise ID**: - ```php - new ExerciseById('exercise-id', 'latest') - ``` +try { + $sessionService->getSessionInfo(new SessionInfoRequest($sessionId)); +} catch (AlgebrakitApiException $e) { + error_log($e->getMessage()); + // Algebrakit API error 401: Could not validate credentials: the provided api-key is not valid +} +``` -2. **By Exercise Specification**: - ```php - new ExerciseBySpec($exerciseSpecObject) - ``` +`AlgebrakitApiException` extends `RuntimeException`, so existing code that catches `RuntimeException` keeps working. -3. **By Session ID** (reuse exercise from existing session): - ```php - new ExerciseBySession('existing-session-id') - ``` +## More -## Error Handling +- [algebrakit-examples](https://github.com/algebrakit/algebrakit-examples): runnable examples in PHP, TypeScript and .NET +- [Documentation](https://docs.algebrakit.com/): [SDKs](https://docs.algebrakit.com/pages/320-SDK/), [API reference](https://docs.algebrakit.com/pages/310-API/), [web components](https://docs.algebrakit.com/pages/410-webcomponents/) and [learning events](https://docs.algebrakit.com/pages/450-learning-events/) +- [OpenAPI specification](https://api.algebrakit.com/openapi/yaml) +- SDKs for other languages: [TypeScript](https://github.com/algebrakit/sdk-typescript) and [.NET](https://github.com/algebrakit/sdk-dotnet) +- A tour of every API call: [`demo/tour.php`](demo/tour.php). Run it with `ALGEBRAKIT_API_KEY= composer demo`. -All service methods throw `RuntimeException` if the API request fails: +## Development -```php -try { - $response = $sessionService->createSession($request); -} catch (RuntimeException $e) { - echo "Error: " . $e->getMessage() . "\n"; -} +```bash +composer install +composer test # unit tests, no API key needed ``` +To release: update [CHANGELOG.md](CHANGELOG.md), then push a tag such as `v1.1.0`. Packagist picks up the tag automatically, and GitHub Actions creates a GitHub release from the changelog. + ## License -MIT License \ No newline at end of file +[MIT](LICENSE) diff --git a/composer.json b/composer.json index ec7362c..b0c3cd8 100644 --- a/composer.json +++ b/composer.json @@ -1,9 +1,17 @@ { "name": "algebrakit/sdk-php", - "description": "Official PHP SDK for the Algebrakit Web Service API — create exercise sessions, retrieve scores, lock/unlock sessions, validate exercises, and fetch session info.", + "description": "Official PHP SDK for the Algebrakit API: create exercise sessions, get scores and session info, lock sessions and validate exercises.", "type": "library", - "keywords": ["algebrakit", "sdk", "math", "education", "exercises", "api-client"], - "homepage": "https://github.com/algebrakit-org/sdk-php", + "keywords": [ + "algebrakit", + "sdk", + "api-client", + "math", + "education", + "edtech", + "exercises" + ], + "homepage": "https://github.com/algebrakit/sdk-php", "license": "MIT", "authors": [ { @@ -12,15 +20,16 @@ } ], "support": { - "issues": "https://github.com/algebrakit-org/sdk-php/issues", - "source": "https://github.com/algebrakit-org/sdk-php" + "issues": "https://github.com/algebrakit/sdk-php/issues", + "source": "https://github.com/algebrakit/sdk-php", + "docs": "https://docs.algebrakit.com/pages/320-SDK/" }, "require": { - "php": "^8.0", - "guzzlehttp/guzzle": "^7.0" + "php": "^8.1", + "guzzlehttp/guzzle": "^7.9" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^10.5" }, "autoload": { "psr-4": { @@ -34,5 +43,9 @@ "psr-4": { "Algebrakit\\SDK\\Tests\\": "tests/" } + }, + "scripts": { + "test": "phpunit", + "demo": "php demo/tour.php" } -} \ No newline at end of file +} diff --git a/demo/play-exercise.php b/demo/play-exercise.php deleted file mode 100644 index 68dcbcd..0000000 --- a/demo/play-exercise.php +++ /dev/null @@ -1,241 +0,0 @@ -createSession( - new CreateSessionRequest( - exercises: [new ExerciseById($exerciseId, 'latest')] - ) - ); - - if (!empty($response) && $response[0]->success && !empty($response[0]->sessions)) { - $session = $response[0]->sessions[0]; - $exerciseHtml = $session->html; - } else { - $error = $response[0]->msg ?? 'Unknown error creating session'; - } -} catch (Exception $e) { - $error = $e->getMessage(); -} - -?> - - - - - - Algebrakit PHP SDK - Web Demo - - - - - - - - - - -

Algebrakit PHP SDK - Web Demo

- - -
- -
- -
- - -
-

Learning Events

-
- - - - - diff --git a/demo/tour.php b/demo/tour.php new file mode 100644 index 0000000..e732cf1 --- /dev/null +++ b/demo/tour.php @@ -0,0 +1,104 @@ + composer demo + * + * To show an exercise to a student in the browser, see the runnable web examples: + * https://github.com/algebrakit/algebrakit-examples + */ + +require __DIR__ . '/../vendor/autoload.php'; + +use Algebrakit\SDK\Exceptions\AlgebrakitApiException; +use Algebrakit\SDK\Models\AkExercise\AK_ContentBlock; +use Algebrakit\SDK\Models\AkExercise\AK_Element; +use Algebrakit\SDK\Models\AkExercise\AK_Exercise; +use Algebrakit\SDK\Models\AkExercise\AK_InteractionBlock; +use Algebrakit\SDK\Models\AkExercise\AK_InteractionMultistep; +use Algebrakit\SDK\Models\AkExercise\AK_MultistepPart; +use Algebrakit\SDK\Models\AkExercise\AK_QuestionMode; +use Algebrakit\SDK\Models\AkExercise\AK_Symbol; +use Algebrakit\SDK\Models\AkExercise\AK_SymbolType; +use Algebrakit\SDK\Models\AkExercise\AK_TaskSimplify; +use Algebrakit\SDK\Models\Requests\CreateSessionRequest; +use Algebrakit\SDK\Models\Requests\ExerciseValidateRequest; +use Algebrakit\SDK\Models\Requests\SessionInfoRequest; +use Algebrakit\SDK\Models\Requests\SessionLockRequest; +use Algebrakit\SDK\Models\Requests\SessionScoreRequest; +use Algebrakit\SDK\Models\Shared\ExerciseById; +use Algebrakit\SDK\Models\Shared\ExerciseBySpec; +use Algebrakit\SDK\Services\SessionService; + +const EXERCISE_ID = 'fa42e943-8213-41a6-8a91-8c22a929ffe9'; // "Solve the equation 6(p - 1) = 4p + 10" + +$apiKey = getenv('ALGEBRAKIT_API_KEY'); +if (!$apiKey) { + fwrite(STDERR, "Set ALGEBRAKIT_API_KEY first, for example: ALGEBRAKIT_API_KEY= composer demo\n"); + exit(1); +} +$apiUrl = getenv('ALGEBRAKIT_API_URL') ?: SessionService::DEFAULT_BASE_URL; +$service = new SessionService($apiKey, baseUrl: $apiUrl); + +// An exercise defined in code instead of in the Algebrakit CMS (AK_Exercise format) +$exerciseSpec = new AK_Exercise( + studentProfile: 'uk_KS5', + questionMode: AK_QuestionMode::ALL_AT_ONCE, + symbols: [new AK_Symbol(name: 'x', type: AK_SymbolType::VARIABLE)], + elements: [ + new AK_Element(blocks: [ + new AK_ContentBlock(content: 'Simplify the following expression.'), + new AK_InteractionBlock(interaction: new AK_InteractionMultistep( + solutionPart: new AK_MultistepPart(task: new AK_TaskSimplify(expression: '2x + 3x')) + )), + ]), + ] +); + +function step(string $title): void +{ + echo "\n$title\n"; +} + +try { + echo "Algebrakit SDK tour\nAPI: $apiUrl\n"; + + step('1. Validate an exercise (validateExercise)'); + $validation = $service->validateExercise(new ExerciseValidateRequest(exerciseId: EXERCISE_ID)); + echo ' valid: ' . var_export($validation->valid, true) . ", marks: {$validation->marks}\n"; + + step('2. Create a session (createSession)'); + $result = $service->createSession(new CreateSessionRequest(exercises: [new ExerciseById(EXERCISE_ID)]))[0]; + if (!$result->success) { + throw new RuntimeException($result->msg ?? 'Session creation failed'); + } + $sessionId = $result->sessions[0]->sessionId; + echo " session ID: $sessionId\n"; + echo " show it in a page with: \n"; + + step('3. Get session info (getSessionInfo)'); + $info = $service->getSessionInfo(new SessionInfoRequest($sessionId)); + echo ' elements: ' . count($info->elements) . "\n"; + + step('4. Get the score (getSessionScore)'); + $scoring = $service->getSessionScore(new SessionScoreRequest($sessionId))->scoring; + echo " marks: {$scoring->marksEarned} of {$scoring->marksTotal}, finished: " . var_export($scoring->finished, true) . "\n"; + + step('5. Lock and unlock the session (lockOrUnlockSession)'); + $service->lockOrUnlockSession(new SessionLockRequest('LOCK', [$sessionId])); + echo " locked: the student can no longer change their answers\n"; + $service->lockOrUnlockSession(new SessionLockRequest('UNLOCK', [$sessionId])); + echo " unlocked\n"; + + step('6. Create a session from an exercise defined in code (ExerciseBySpec)'); + $specValidation = $service->validateExercise(new ExerciseValidateRequest(exerciseSpec: $exerciseSpec)); + echo ' valid: ' . var_export($specValidation->valid, true) . ", marks: {$specValidation->marks}\n"; + $specResult = $service->createSession(new CreateSessionRequest(exercises: [new ExerciseBySpec($exerciseSpec)]))[0]; + echo " session ID: {$specResult->sessions[0]->sessionId}\n"; + + echo "\nDone.\n"; +} catch (AlgebrakitApiException $e) { + fwrite(STDERR, "\nDemo failed: {$e->getMessage()}\n"); + exit(1); +} diff --git a/demo/various-functions.php b/demo/various-functions.php deleted file mode 100644 index 91fb873..0000000 --- a/demo/various-functions.php +++ /dev/null @@ -1,133 +0,0 @@ -getExerciseInfo($exerciseInfoRequest); - - if ($exerciseInfoResponse) { - echo "Exercise Info for " . EXERCISE_ID . ": Course Name: {$exerciseInfoResponse->courseName}, Type: {$exerciseInfoResponse->type}\n"; - } else { - echo "Failed to retrieve exercise published info.\n"; - } - - // Validate the exercise - $validateRequest = new ExerciseValidateRequest(exerciseId: EXERCISE_ID); - $validateResponse = $sessionService->validateExercise($validateRequest); - - if ($validateResponse) { - echo "Exercise Validate for " . EXERCISE_ID . ": Valid: " . ($validateResponse->valid ? 'true' : 'false') . ", Marks: {$validateResponse->marks}\n"; - } else { - echo "Failed to validate exercise.\n"; - } - - // Create a session for the exercise - $createSessionRequest = new CreateSessionRequest( - exercises: [new ExerciseById(EXERCISE_ID, 'latest')] - ); - - $createSessionResponse = $sessionService->createSession($createSessionRequest); - - if (!empty($createSessionResponse)) { - foreach ($createSessionResponse as $response) { - if ($response->success && !empty($response->sessions)) { - $session = $response->sessions[0]; - $sessionId = $session->sessionId; - echo "Session created successfully. Session ID: {$sessionId}\n"; - - // Get scoring results for the created session - $sessionScoreRequest = new SessionScoreRequest($sessionId); - $sessionScoreResponse = $sessionService->getSessionScore($sessionScoreRequest); - - if ($sessionScoreResponse) { - echo "Scoring Results:\n"; - foreach ($sessionScoreResponse->questions as $question) { - echo "Question: {$question->id}, Marks Earned: {$question->scoring->marksEarned}, Total Marks: {$question->scoring->marksTotal}\n"; - } - } else { - echo "Failed to retrieve scoring results.\n"; - } - - // Get session info - $sessionInfoRequest = new SessionInfoRequest($sessionId); - $sessionInfoResponse = $sessionService->getSessionInfo($sessionInfoRequest); - - if ($sessionInfoResponse) { - echo "Session Info successfully retrieved.\n"; - } else { - echo "Failed to retrieve session info.\n"; - } - } else { - echo "Failed to create session.\n"; - } - } - } else { - echo "No sessions were created.\n"; - } - // Create a session using an AK_Exercise specification - echo "\nCreating session from AK_Exercise spec...\n"; - $exerciseSpec = new AK_Exercise( - studentProfile: 'uk_KS5', - questionMode: AK_QuestionMode::ALL_AT_ONCE, - symbols: [ - new AK_Symbol(name: 'x', type: AK_SymbolType::VARIABLE), - ], - elements: [ - new AK_Element(blocks: [ - new AK_ContentBlock(content: 'Simplify the following expression.'), - new AK_InteractionBlock( - interaction: new AK_InteractionMultistep( - solutionPart: new AK_MultistepPart( - task: new AK_TaskSimplify(expression: '2x + 3x') - ) - ) - ), - ]), - ] - ); - - // Validate the exercise spec - $specValidateResponse = $sessionService->validateExercise( - new ExerciseValidateRequest(exerciseSpec: $exerciseSpec) - ); - echo "Spec validation: Valid=" . ($specValidateResponse->valid ? 'true' : 'false') . ", Marks={$specValidateResponse->marks}\n"; - - // Create session from spec - $specSessionResponse = $sessionService->createSession( - new CreateSessionRequest(exercises: [new ExerciseBySpec(exerciseSpec: $exerciseSpec)]) - ); - if (!empty($specSessionResponse) && $specSessionResponse[0]->success && !empty($specSessionResponse[0]->sessions)) { - echo "Session from spec created. Session ID: {$specSessionResponse[0]->sessions[0]->sessionId}\n"; - } - -} catch (Exception $e) { - echo "An error occurred: " . $e->getMessage() . "\n"; -} \ No newline at end of file diff --git a/openapi-bundled.yaml b/openapi-bundled.yaml deleted file mode 100644 index 6c19bcc..0000000 --- a/openapi-bundled.yaml +++ /dev/null @@ -1,3789 +0,0 @@ -openapi: 3.0.3 -info: - title: Algebrakit Web Service API - version: 2.0.0 - x-logo: - url: https://public.algebrakit.nl/logo_white.png - altText: Algebrakit Logo - description: | - This API allows creating and managing exercise sessions, retrieve session information and scores, and lock or unlock sessions. -servers: - - url: https://api.algebrakit.com - description: Production server (Ireland) - - url: https://prod.api.sg-1.algebrakit.com - description: Production server (Singapore) -paths: - /session/create: - post: - summary: Create sessions - description: | - Create new sessions for Algebrakit exercises. Multiple sessions can be created in one call to optimize performance. - The response contains a unique session ID for each session. It also offers optimized HTML code which you can insert into your web page. - This HTML code includes allows for faster initialization as the web component does not need to retrieve data from the web service. - operationId: sessionCreate - requestBody: - required: true - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/CreateSessionRequest' - examples: - example1: - $ref: '#/components/examples/CreateSessionRequestExampleMultiple' - responses: - '200': - description: Successful creation of sessions - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/CreateSessionResponse' - examples: - example1: - $ref: '#/components/examples/CreateSessionResponseExample' - security: - - ApiKeyAuth: [] - /session/retrieve: - post: - summary: Retrieve sessions - description: | - Returns the same session information produced by /session/create, but for for existing sessions. - Used to efficiently render the web components for existing sessions. - - Note that restoring an existing exercise can be done without using this endpoint, simply by the `akit-exercise` or `akit-interaction` web components: - ``` - - ``` - The web component will automatically retrieve all needed information (question type, learning events, etc) from the server. - - However, this roundtrip impacts page load time, - especially if multiple exercises are on the page. The `/session/retrieve` endpoint lets you obtain optimized HTML code for multiple sessions in one call. - Inserting this optimized HTML into the webpage prevents a server roundtrip per exercise and allows the exercises to show instantly. - operationId: sessionRetrieve - requestBody: - required: true - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/SessionRetrieveRequest' - examples: - example1: - $ref: '#/components/examples/SessionRetrieveRequestExample' - responses: - '200': - description: Successfully obtained session data - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/SessionRetrieveResponse' - examples: - example1: - $ref: '#/components/examples/SessionRetrieveResponseExample' - security: - - ApiKeyAuth: [] - /session/score: - post: - summary: Get session scores - description: | - Retrieve results from an exercise session. This endpoint allows you to get the scores and other details of the session. - You can optionally lock the session to prevent any further modifications. - operationId: sessionScore - requestBody: - required: true - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/SessionScoreRequest' - examples: - example1: - $ref: '#/components/examples/SessionScoreRequestExample' - responses: - '200': - description: Request succeeded - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/SessionScoreResponse' - examples: - example1: - $ref: '#/components/examples/SessionScoreResponseExample' - security: - - ApiKeyAuth: [] - /sessions/lock: - post: - summary: (Un)lock sessions. - description: | - Lock or unlock exercise sessions. Locked sessions do not allow modifications such as new evaluation events. - This functionality is mainly intended for assessments to guarantee nobody changes the results afterward. - - There are two types of lock actions. You can later undo a `LOCK` action with an `UNLOCK` action. A `FINALIZE` action locks a session forever. - - You can lock or unlock a batch of sessions in a single request. - operationId: sessionLock - requestBody: - required: true - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/SessionLockRequest' - examples: - example1: - $ref: '#/components/examples/SessionLockRequestExample' - responses: - '200': - description: Sessions successfully locked or unlocked - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/SessionLockResponse' - security: - - ApiKeyAuth: [] - /session/info: - post: - summary: Retrieve session info - description: | - Retrieve detailed information about a session, including exercise layout, solutions, and scoring results. - This endpoint provides human-readable (or AI-readable) information about the session, with enhanced annotation data - that includes complete question views with student inputs. - - This endpoint will ultimately replace the `/session/score` endpoint. - operationId: sessionInfo - requestBody: - required: true - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/SessionInfoRequest' - examples: - example1: - $ref: '#/components/examples/SessionInfoRequestExample' - responses: - '200': - description: Request succeeded - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/SessionInfoResponse' - examples: - example1: - $ref: '#/components/examples/SessionInfoResponseExample' - security: - - ApiKeyAuth: [] - /exercise/validate: - post: - summary: Validate exercise - description: Checks whether an exercise is valid (i.e., that it runs) and obtains general information. - operationId: exerciseValidate - requestBody: - required: true - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/ExerciseValidateRequest' - examples: - example1: - $ref: '#/components/examples/ExerciseValidateRequestExample' - responses: - '200': - description: Request was successful - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/ExerciseValidateResponse' - examples: - example1: - $ref: '#/components/examples/ExerciseValidateResponseExample' - security: - - ApiKeyAuth: [] - /exercise/published-info: - post: - summary: Retrieve exercise info - description: | - Retrieve information about the structure, versions, and history of an exercise in Algebrakit's CMS. - operationId: exerciseInfo - requestBody: - required: true - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/ExerciseInfoRequest' - examples: - example1: - $ref: '#/components/examples/ExerciseInfoRequestExample' - responses: - '200': - description: Information about the structure of the exercise. - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/ExerciseInfoResponse' - examples: - example1: - $ref: '#/components/examples/ExerciseInfoResponseExample' -components: - securitySchemes: - ApiKeyAuth: - type: apiKey - in: header - name: X-API-KEY - schemas: - IAK_Exercise: - type: object - properties: - type: - type: string - const: AK_Exercise - studentProfile: - type: string - description: ID of the student profile, which defines school level, language, etc. - symbols: - description: List of mathematical symbols (variables, units, functions, etc.) used in the exercise - type: array - items: - type: object - properties: - name: - type: string - description: The notation of the mathematical symbol in LaTeX notation. For example, "x", "\angle A_{1}", etc. - type: - type: string - enum: - - VARIABLE - - CONSTANT - - FUNCTION - - FREEVARIABLE - description: The type of the math symbol - synonym: - description: An optional synonym for the math symbol. This is required if a symbol has multiple interpretations. E.g. "m" can be mass or meter. Defining a synonym allows the exercise author to specify which interpretation to use in the exercise. E.g. "mMass" and "mMeter" - type: string - addToFormulaEditor: - description: Whether to add the symbol as a button to the formula editor. - type: boolean - notations: - description: Alternative notations for the symbol. For example, a line segment "AB" can also be written as "BA". - type: array - items: - type: string - required: - - name - - type - additionalProperties: false - elements: - type: array - items: - type: object - properties: - blocks: - type: array - items: - oneOf: - - type: object - properties: - type: - type: string - const: CONTENT - content: - type: string - description: The content in this block in HTML format and LaTeX notation for math expressions. For example, "Calculate <latex>x</latex> if <latex>2x + 3 = 7</latex>". - required: - - type - - content - additionalProperties: false - - type: object - properties: - type: - type: string - const: INTERACTION - interaction: - oneOf: - - type: object - properties: - type: - type: string - const: CHOICE - description: The question type of this interaction, which is "CHOICE" for multiple choice questions. - refId: - description: An optional reference ID for this interaction. If not provided, a pseudo ID will be generated. - type: string - instruction: - description: The instruction or question prompt for this interaction - type: string - scored: - description: Whether this interaction should be scored or not. Defaults to true. - type: boolean - hints: - description: An optional list of general hints for this interaction. These are hints to get the student started and will be shown before any successful attempt is made. - type: array - items: - type: string - spec: - type: object - properties: - options: - type: array - items: - type: object - properties: - content: - type: string - description: The content of the choice option in HTML format with LaTeX embedded in <latex>...</latex> tags. - correct: - type: boolean - description: If true, this option is correct - required: - - content - - correct - additionalProperties: false - description: An array of choice options - shuffle: - type: boolean - description: If true, the options are shuffled - multipleSelect: - type: boolean - description: If true, multiple options can be selected - required: - - options - - shuffle - - multipleSelect - additionalProperties: false - required: - - type - - spec - additionalProperties: false - - type: object - properties: - type: - type: string - const: FILL_IN_THE_BLANKS - description: The question type of this interaction, which is "FILL_IN_THE_BLANKS" for fill-in-the-blank questions. - refId: - description: An optional reference ID for this interaction. If not provided, a pseudo ID will be generated. - type: string - instruction: - description: The instruction or question prompt for this interaction - type: string - scored: - description: Whether this interaction should be scored or not. Defaults to true. - type: boolean - hints: - description: An optional list of general hints for this interaction. These are hints to get the student started and will be shown before any successful attempt is made. - type: array - items: - type: string - content: - type: string - description: The content string in HTML with optional embedded LaTeX and placeholders in square brackets for fields. E.g. 'Calculate <latex>\sqrt{x^2}=</latex> <blank id="B1"></blank>.' - blanks: - type: array - items: - type: object - properties: - id: - type: string - description: The unique identifier for this blank, used as a placeholder in the content string - size: - type: string - enum: - - SMALL - - MEDIUM - - LARGE - description: The size of the blank field - type: - type: string - enum: - - EXPRESSION - - SELECTION - description: The type of the blank field - input: - anyOf: - - type: object - properties: - task: - oneOf: - - type: object - properties: - type: - type: string - const: SIMPLIFY - description: Indicates the task is to simplify the given mathematical expression using mathematical and algebraic rules. - expression: - type: string - description: The LaTeX string representing the mathematical expression to simplify. - required: - - type - - expression - additionalProperties: false - - type: object - properties: - type: - type: string - const: SOLVE - description: Indicates the task is to solve the given equation or inequality for a specific variable. - expression: - type: string - description: The LaTeX string representing the equation or inequality to solve. For example, "x^2-4=0" or "2x+3>5". - variable: - type: string - description: The variable to solve for, e.g. 'x' - domain: - description: Optional domain of the solution, written as an inequality, e.g. 'x\geq 0', '05". - variable: - type: string - description: The variable to solve for, e.g. 'x' - domain: - description: Optional domain of the solution, written as an inequality, e.g. 'x\geq 0', '05". - variable: - type: string - description: The variable to solve for, e.g. 'x' - domain: - description: Optional domain of the solution, written as an inequality, e.g. 'x\geq 0', '05". - variable: - type: string - description: The variable to solve for, e.g. 'x' - domain: - description: Optional domain of the solution, written as an inequality, e.g. 'x\geq 0', '05". - variable: - type: string - description: The variable to solve for, e.g. 'x' - domain: - description: Optional domain of the solution, written as an inequality, e.g. 'x\geq 0', '05". - variable: - type: string - description: The variable to solve for, e.g. 'x' - domain: - description: Optional domain of the solution, written as an inequality, e.g. 'x\geq 0', '05". - variable: - type: string - description: The variable to solve for, e.g. 'x' - domain: - description: Optional domain of the solution, written as an inequality, e.g. 'x\geq 0', '05". - variable: - type: string - description: The variable to solve for, e.g. 'x' - domain: - description: Optional domain of the solution, written as an inequality, e.g. 'x\geq 0', '0 tags for mathematical input fields - - tags for multiple choice options - - \boxed{\color{blue} ...} for inline math expression inputs - gapIndex: - type: integer - description: Index of the gap in a Fill in the Blanks question, starting from 1 - row: - type: integer - description: Row index in a Math Table question, starting from 1 - col: - type: integer - description: Column index in a Math Table question, starting from 1 - EventResultInfo: - type: object - required: - - timestamp - - event - properties: - timestamp: - type: integer - description: Timestamp of the event in milliseconds since the epoch. - event: - $ref: '#/components/schemas/EventType' - exerciseStatus: - description: | - Applies to evaluation events. The status of the students work for this math problem. - $ref: '#/components/schemas/ExerciseStatus' - inputStatus: - description: | - Applies to evaluation events. The status of the students work on the current part of this math problem. - $ref: '#/components/schemas/ExerciseStatus' - tags: - type: array - description: References to skill tags for this event, which indicate skills and optional misconceptions. Descriptions of these tags are provided in the `tagDescriptions` property of the session info. - items: - $ref: '#/components/schemas/SkillTag' - skillsTodo: - type: array - description: Estimate of skills needed to finish the derivation in this part. Descriptions of these skill IDs are provided in the `tagDescriptions` property of the session info. - items: - type: string - annotations: - type: array - description: Meaningful information about the event. What information is provided depends on the question type. - items: - $ref: '#/components/schemas/InfoAnnotation' - InteractionResultInfo: - type: object - required: - - id - - type - - status - - progress - - tags - - events - properties: - status: - $ref: '#/components/schemas/ExerciseStatus' - progress: - type: number - format: float - minimum: 0 - maximum: 1 - description: Progress in range [0..1]. Note that progress is not the same as marks. - scoring: - $ref: '#/components/schemas/InteractionScoring' - tags: - type: array - description: | - References to skill tags for this interaction, compiled from errors, hints, completed derivations, and next step from - incomplete derivations. See the `events`` property to see which event generated what skill tags. - Descriptions of these tags are provided in the `tagDescriptions`` property of the session info. - items: - $ref: '#/components/schemas/SkillTag' - events: - type: array - items: - $ref: '#/components/schemas/EventResultInfo' - InteractionInfo: - type: object - allOf: - - $ref: '#/components/schemas/ElementItemInfoBase' - - type: object - required: - - id - - itemType - - interactionType - - solution - - result - properties: - id: - type: string - description: Unique identifier ('ref-id') for this interaction. - itemType: - type: string - enum: - - INTERACTION - interactionType: - $ref: '#/components/schemas/InteractionType' - content: - type: string - nullable: true - solution: - type: string - derivation: - type: array - items: - $ref: '#/components/schemas/DerivationPart' - nullable: true - result: - $ref: '#/components/schemas/InteractionResultInfo' - TextItemInfo: - type: object - allOf: - - $ref: '#/components/schemas/ElementItemInfoBase' - - type: object - required: - - itemType - - content - properties: - itemType: - type: string - enum: - - TEXT - content: - type: string - description: The content, which can include HTML tags for tables, lists, and other formatting. Mathematical expressions are encoded in latex and wrapped in $ ... $. - ElementItemInfo: - type: object - required: - - itemType - discriminator: - propertyName: itemType - oneOf: - - $ref: '#/components/schemas/InteractionInfo' - - $ref: '#/components/schemas/TextItemInfo' - ElementInfo: - type: object - description: An Element is either a question block or a content block. - properties: - id: - type: string - description: Unique identifier ('ref-id') for this element block. - type: - type: string - enum: - - INSTRUCTION - - QUESTION - description: The type of the element. INSTRUCTION elements have no scorable interactions. QUESTION elements contain one or more scorable interactions. - items: - type: array - items: - $ref: '#/components/schemas/ElementItemInfo' - SessionInfoResponse: - type: object - required: - - elements - - tagDescriptions - properties: - elements: - type: array - items: - $ref: '#/components/schemas/ElementInfo' - tagDescriptions: - $ref: '#/components/schemas/TagDescriptions' - scoring: - $ref: '#/components/schemas/ScoringResult' - ExerciseValidateRequest: - oneOf: - - type: object - required: - - exerciseId - properties: - exerciseId: - type: string - description: Unique identifier for the session to score. - version: - oneOf: - - type: string - - type: number - description: The published version number of the exercise (number) or 'latest' (default). The latter refers to the current version of the exercise. - - type: object - required: - - exerciseSpec - properties: - exerciseSpec: - oneOf: - - $ref: '#/components/schemas/IAK_Exercise' - - type: object - description: Legacy exercise specification format (internal format) - description: | - An exercise specification in the AK_Exercise format (recommended) or as obtained from the exercise authoring tool. For the full IAK_Exercise specification, see [Exercise Specification](https://docs.algebrakit.com/pages/315-exercise-specification/). - ExerciseValidateResponse: - type: object - required: - - valid - properties: - success: - type: boolean - description: Indicates if the request was successful. - valid: - type: boolean - description: If false, this exercise has a problem and is not suitable to create sessions. - msg: - type: string - description: A description of the problem in case the request failed or the exercise is not valid - marks: - type: number - description: The number of marks for the exercise. - random: - type: boolean - description: Indicates if the exercise is random. - interactions: - type: object - description: Details of interactions (question type) within the exercise. - additionalProperties: - $ref: '#/components/schemas/InteractionDescription' - ExerciseInfoRequest: - type: object - description: Request to retrieve information about an exercise. - required: - - id - properties: - id: - type: string - description: The exercise ID. - ExerciseInfoCommitData: - type: object - description: Commit data for an exercise. - properties: - versionNumber: - type: string - commitDate: - type: string - format: date-time - commitMessage: - type: string - authorUsername: - type: string - ExerciseInfoInteraction: - type: object - description: Interaction info for an exercise version. - properties: - block: - description: Describes where the interaction is located. - type: string - refId: - description: The ID of the interaction in the exercise. - type: string - refName: - description: The name of the interaction in the exercise editor. - type: string - type: - $ref: '#/components/schemas/InteractionType' - ExerciseInfoResource: - type: object - description: Resource info for an exercise version. - properties: - refId: - description: The ID of the resource in the exercise. - type: string - type: - type: string - refName: - description: The name of the resource in the exercise editor. - type: string - ExerciseInfoPublishedVersion: - type: object - description: Published version info for an exercise. - properties: - name: - type: string - majorVersion: - oneOf: - - type: integer - - type: string - enum: - - latest - metadata: - type: object - additionalProperties: - type: string - contentVersion: - type: string - numberOfLevels: - type: integer - interactions: - type: array - items: - $ref: '#/components/schemas/ExerciseInfoInteraction' - resources: - type: array - items: - $ref: '#/components/schemas/ExerciseInfoResource' - ExerciseInfoResponse: - type: object - description: Information about the structure of the exercise. - properties: - commitHistory: - type: array - items: - $ref: '#/components/schemas/ExerciseInfoCommitData' - id: - type: string - publishedVersions: - type: array - items: - $ref: '#/components/schemas/ExerciseInfoPublishedVersion' - courseName: - type: string - type: - type: string - description: regular exercise, single reference, or arrangement exercise - examples: - CreateSessionRequestExampleMultiple: - value: - exercises: - - exerciseId: fa42e943-8213-41a6-8a91-8c22a929ffe9 - version: latest - - exerciseId: 0ce4c011-28e3-4423-9429-75ae28e3bc10 - version: latest - assessmentMode: true - scoringModel: default - apiVersion: 2 - CreateSessionResponseExample: - value: - - success: true - sessions: - - success: true - sessionId: 691ddb39-e830-41ae-8f97-8c94dcb6120b - type: SINGLE - solution: false - marksTotal: 1 - interactions: - lNzHy: - type: MULTISTEP - marks: 1 - scorable: true - html: - - success: true - sessions: - - success: true - sessionId: 62ea6f14-bfcc-42a7-8682-7aff31339e24 - type: SINGLE - solution: false - marksTotal: 1 - interactions: - mFzbB: - type: GEOMETRY - marks: 1 - scorable: true - html: - SessionRetrieveRequestExample: - value: - sessionIds: - - 77aca88f-e2f4-4daf-ba2e-41008fee1f9a - SessionRetrieveResponseExample: - value: - - success: true - sessionId: 77aca88f-e2f4-4daf-ba2e-41008fee1f9a - type: SINGLE - solution: false - marksTotal: 1 - interactions: - lNzHy: - type: MULTISTEP - marks: 1 - scorable: true - html: '' - SessionScoreRequestExample: - value: - sessionId: 77aca88f-e2f4-4daf-ba2e-41008fee1f9a - lockSession: true - withTags: true - withEvents: true - withEventAnnotations: true - withScoring: true - SessionScoreResponseExample: - value: - questions: - - id: Q1 - interactions: - - id: lNzHy - type: MULTISTEP - progress: 0.5 - status: CORRECT - events: - - timestamp: 1752395844080 - event: EVALUATE - annotations: - - type: INPUT_EXPRESSION - expr: - - content: 6p-1=4p+10 - mimeType: application/x-tex - - type: ERROR_FEEDBACK - expr: - - mimeType: application/x-tex - content: $$6 p - \begingroup\color{darkred}\begingroup 1\endgroup\endgroup=4 p + 10$$ - akit: DerivationContext[Solve[6 p+Highlight[-1]=4 p+10, p, True],Exact=>True] - main: - - mimeType: text/plain - content: You did not compute the multiplication correctly. - sub: - - mimeType: text/plain - content: 'You did not multiply all terms of ' - - mimeType: application/x-tex - content: $p - 1$ - akit: p+-1 - - mimeType: text/plain - content: ' by ' - - mimeType: application/x-tex - content: $6$ - akit: '6' - - mimeType: text/plain - content: . - exerciseStatus: ERROR - progress: 0 - inputStatus: ERROR - - timestamp: 1752395846387 - event: EVALUATE - annotations: - - type: INPUT_EXPRESSION - expr: - - content: 6p-6=4p+10 - mimeType: application/x-tex - exerciseStatus: CORRECT - progress: 0 - inputStatus: CORRECT - - timestamp: 1752395850393 - event: HINT - annotations: - - type: HINT - main: - - mimeType: text/plain - content: 'Balance method: subtract ' - - mimeType: application/x-tex - content: $4 p $ - akit: 4 p - - mimeType: text/plain - content: ' from both sides' - expr: - - mimeType: application/x-tex - content: $$\begingroup\color{#336699}\begingroup 6 p \endgroup\endgroup- 6=\begingroup\color{#336699}\begingroup 4 p \endgroup\endgroup+ 10$$ - akit: DerivationContext[Highlight[6 p]+-6=Highlight[4 p]+10,Exact=>True] - - timestamp: 1752395852101 - event: EVALUATE - annotations: - - type: INPUT_EXPRESSION - expr: - - content: 2p=16 - mimeType: application/x-tex - exerciseStatus: CORRECT - progress: 0 - inputStatus: CORRECT - - timestamp: 1752395854898 - event: EVALUATE - annotations: - - type: INPUT_EXPRESSION - expr: - - content: p=\frac{16}{2} - mimeType: application/x-tex - exerciseStatus: CORRECT - progress: 0.5 - inputStatus: CORRECT - - timestamp: 1752395865482 - event: HINT - annotations: - - type: HINT - main: - - mimeType: text/plain - content: 'Calculate: ' - - mimeType: application/x-tex - content: $\frac{16}{2}$ - akit: 16/2 - expr: - - mimeType: application/x-tex - content: $$p =\begingroup\color{#336699}\begingroup \frac{16}{2}\endgroup\endgroup$$ - akit: DerivationContext[Solve[p=Highlight[16/2], p, True],Exact=>True] - scoring: - marksEarned: 0 - marksTotal: 1 - penalties: - marksPenalty: 0 - hintsRequested: 2 - mathErrors: 1 - finished: false - scoring: - finished: false - marksTotal: 1 - marksEarned: 0 - penalties: - marksPenalty: 0 - hintsRequested: 2 - mathErrors: 1 - scoring: - finished: false - marksEarned: 0 - marksTotal: 1 - penalties: - marksPenalty: 0 - hintsRequested: 2 - mathErrors: 1 - SessionLockRequestExample: - value: - action: LOCK - sessionIds: - - 77aca88f-e2f4-4daf-ba2e-41008fee1f9a - SessionInfoRequestExample: - value: - sessionId: 77aca88f-e2f4-4daf-ba2e-41008fee1f9a - withDerivations: true - withTags: true - withEvents: true - withEventAnnotations: true - withScoring: true - SessionInfoResponseExample: - value: - success: true - creationTimestamp: 1752395839 - elements: - - id: Q1 - type: QUESTION - items: - - itemType: TEXT - content: Solve the equation. - - itemType: TEXT - content: $$6\left ( p - 1\right ) = 4p + 10$$ - - id: lNzHy - itemType: INTERACTION - interactionType: MULTISTEP - result: - status: CORRECT - progress: 0.5 - events: - - timestamp: 1752395844080 - event: EVALUATE - tags: - - id: ExpandSingleBrackets - weight: 100 - errors: - - SkippingTerms - source: ERROR_FEEDBACK - annotations: - - type: INPUT - content: $6p-1=4p+10$ - - type: ERROR_FEEDBACK - content:

You did not expand the parentheses correctly.

You did not multiply all terms of $p - 1$ by $6$ . - expression: $6 p - \begingroup\color{darkred}\begingroup 1\endgroup\endgroup=4 p + 10$ - exerciseStatus: ERROR - progress: 0 - inputStatus: ERROR - scoring: - marksEarned: 0 - marksTotal: 1 - penalties: - marksPenalty: 0 - hintsRequested: 2 - mathErrors: 1 - finished: false - solution: $$p =8$$ - derivation: - - expression: $$6 \left(p - 1\right)=4 p + 10$$ - - result: $$6 p - 6=4 p + 10$$ - skills: - - ExpandSingleBrackets - description: Expand the brackets - - result: $$6 p - 4 p - 6=10$$ - skills: - - EquationsBalanceMethodArrangeTerms - - EquationsOperationsAdd - description: 'Balance method: subtract $4 p $ from both sides' - - result: $$2 p - 6=10$$ - skills: - - AlgebraAddingLikeTerms - description: Combine the similar terms - - result: $$2 p =16$$ - skills: - - EquationsBalanceMethodArrangeTerms - - EquationsOperationsAdd - description: 'Balance method: add $6$ to both sides.' - - result: $$p =8$$ - skills: - - EquationsBalanceMethodDivideCoefficient - - EquationsOperationsMultiply - description: Divide both sides by $2$ - tagDescriptions: - ExpandSingleBrackets: - descr: - en: Expanding a single pair of parentheses - stepType: ALGEBRA - errors: - - id: SkippingTerms - type: - - MISCONCEPTION - descr: - en: Not multiplying all terms by the factored constant. - EquationsBalanceMethodArrangeTerms: - descr: - en: Arrange terms with variable to the left and other terms to the right. - stepType: STRATEGY - EquationsOperationsAdd: - descr: - en: Add or subtract terms using the balance method - stepType: STRATEGY - AlgebraAddingLikeTerms: - descr: - en: Adding like terms - stepType: ALGEBRA - EquationsBalanceMethodDivideCoefficient: - descr: - en: Divide by the coefficient of the variable to get the solution. - stepType: STRATEGY - EquationsOperationsMultiply: - descr: - en: Multiply or divide an equation using the balance method - stepType: STRATEGY - scoring: - finished: false - marksEarned: 0 - marksTotal: 1 - ExerciseValidateRequestExample: - value: - exerciseId: e6cef2b5-b114-4931-ac68-b77289d7c0a3 - ExerciseValidateResponseExample: - value: - success: true - valid: true - marks: 1 - random: true - interactions: - Wp0Hr: - type: MULTISTEP - marks: 1 - ExerciseInfoRequestExample: - value: - id: fa42e943-8213-41a6-8a91-8c22a929ffe9 - ExerciseInfoResponseExample: - value: - commitHistory: [] - id: fa42e943-8213-41a6-8a91-8c22a929ffe9 - endpoint: https://algebrakit.eu - publishedVersions: - - name: A linear equation - resources: [] - metadata: - question-type: Multistep - creator: Algebrakit - contentVersion: '11.0' - numberOfLevels: 0 - interactions: - - block: Question a - refId: lNzHy - type: MULTISTEP - refName: I1 - majorVersion: latest - courseName: AlgebraKiT-demo - type: regular exercise diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..f8b2ca6 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,13 @@ + + + + + tests + + + diff --git a/src/Exceptions/AlgebrakitApiException.php b/src/Exceptions/AlgebrakitApiException.php new file mode 100644 index 0000000..ddd59e6 --- /dev/null +++ b/src/Exceptions/AlgebrakitApiException.php @@ -0,0 +1,27 @@ +statusCode; + } +} diff --git a/src/Services/SessionService.php b/src/Services/SessionService.php index dc84be0..2aa8379 100644 --- a/src/Services/SessionService.php +++ b/src/Services/SessionService.php @@ -2,226 +2,199 @@ namespace Algebrakit\SDK\Services; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; +use Algebrakit\SDK\Exceptions\AlgebrakitApiException; use Algebrakit\SDK\Models\Requests\CreateSessionRequest; -use Algebrakit\SDK\Models\Requests\SessionScoreRequest; +use Algebrakit\SDK\Models\Requests\ExerciseInfoRequest; +use Algebrakit\SDK\Models\Requests\ExerciseValidateRequest; use Algebrakit\SDK\Models\Requests\SessionInfoRequest; -use Algebrakit\SDK\Models\Requests\SessionRetrieveRequest; use Algebrakit\SDK\Models\Requests\SessionLockRequest; -use Algebrakit\SDK\Models\Requests\ExerciseValidateRequest; -use Algebrakit\SDK\Models\Requests\ExerciseInfoRequest; +use Algebrakit\SDK\Models\Requests\SessionRetrieveRequest; +use Algebrakit\SDK\Models\Requests\SessionScoreRequest; use Algebrakit\SDK\Models\Responses\CreateSessionResponse; -use Algebrakit\SDK\Models\Responses\SessionScoreResponse; +use Algebrakit\SDK\Models\Responses\ExerciseInfoResponse; +use Algebrakit\SDK\Models\Responses\ExerciseValidateResponse; use Algebrakit\SDK\Models\Responses\SessionInfoResponse; -use Algebrakit\SDK\Models\Responses\SessionRetrieveResponse; use Algebrakit\SDK\Models\Responses\SessionLockResponse; -use Algebrakit\SDK\Models\Responses\ExerciseValidateResponse; -use Algebrakit\SDK\Models\Responses\ExerciseInfoResponse; -use RuntimeException; - +use Algebrakit\SDK\Models\Responses\SessionRetrieveResponse; +use Algebrakit\SDK\Models\Responses\SessionScoreResponse; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\RequestException; +use JsonSerializable; + +/** + * Client for the Algebrakit web service API. + * + * Example: + * $sessionService = new SessionService(getenv('ALGEBRAKIT_API_KEY')); + */ class SessionService { + /** The Algebrakit API in the default region (Ireland). */ + public const DEFAULT_BASE_URL = 'https://api.algebrakit.com'; + private Client $httpClient; private string $apiKey; - public function __construct(string $apiKey, ?Client $httpClient = null, string $baseUrl = 'https://api.algebrakit.com') - { + /** + * @param string $apiKey Your Algebrakit API key. Keep it on your server; never send it to the browser. + * @param Client|null $httpClient Your own Guzzle client, if you need one. It must send the API key itself. + * @param string $baseUrl Base URL of the Algebrakit API, for example to use another region. + * @param float $timeout Request timeout in seconds. Ignored if you pass your own $httpClient. + */ + public function __construct( + string $apiKey, + ?Client $httpClient = null, + string $baseUrl = self::DEFAULT_BASE_URL, + float $timeout = 30.0 + ) { $this->apiKey = $apiKey; $this->httpClient = $httpClient ?? new Client([ 'base_uri' => $baseUrl, + 'timeout' => $timeout, 'headers' => [ 'X-API-KEY' => $this->apiKey, 'Content-Type' => 'application/json', - 'Accept' => 'application/json' - ] + 'Accept' => 'application/json', + ], ]); } /** * Creates new exercise sessions. - * - * @param CreateSessionRequest $request The request containing session details - * @return array Array of CreateSessionResponse objects - * @throws RuntimeException If the request fails + * + * @param CreateSessionRequest $request The exercises to create sessions for + * @return CreateSessionResponse[] For each exercise, whether it succeeded and the created sessions + * @throws AlgebrakitApiException If the request fails */ public function createSession(CreateSessionRequest $request): array { - try { - $response = $this->httpClient->post('/session/create', [ - 'json' => $request->jsonSerialize() - ]); - - $data = json_decode($response->getBody()->getContents(), true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new RuntimeException('Failed to decode JSON response: ' . json_last_error_msg()); - } - - $results = []; - foreach ($data as $item) { - $results[] = CreateSessionResponse::fromArray($item); - } - - return $results; - } catch (GuzzleException $e) { - throw new RuntimeException('Failed to create session: ' . $e->getMessage(), 0, $e); - } + return array_map( + fn (array $item) => CreateSessionResponse::fromArray($item), + $this->post('/session/create', $request) + ); } /** * Retrieves scores for a specific session. - * - * @param SessionScoreRequest $request The request containing session ID and lock option + * + * @param SessionScoreRequest $request The session ID, and whether to lock the session * @return SessionScoreResponse The response containing session scores - * @throws RuntimeException If the request fails + * @throws AlgebrakitApiException If the request fails */ public function getSessionScore(SessionScoreRequest $request): SessionScoreResponse { - try { - $response = $this->httpClient->post('/session/score', [ - 'json' => $request->jsonSerialize() - ]); - - $data = json_decode($response->getBody()->getContents(), true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new RuntimeException('Failed to decode JSON response: ' . json_last_error_msg()); - } - - return SessionScoreResponse::fromArray($data); - } catch (GuzzleException $e) { - throw new RuntimeException('Failed to get session score: ' . $e->getMessage(), 0, $e); - } + return SessionScoreResponse::fromArray($this->post('/session/score', $request)); } /** * Locks or unlocks exercise sessions. - * - * @param SessionLockRequest $request The request containing session IDs and action + * + * @param SessionLockRequest $request The action ('LOCK', 'UNLOCK' or 'FINALIZE') and the session IDs * @return SessionLockResponse The response for the lock or unlock action - * @throws RuntimeException If the request fails + * @throws AlgebrakitApiException If the request fails */ public function lockOrUnlockSession(SessionLockRequest $request): SessionLockResponse { - try { - $response = $this->httpClient->post('/session/lock', [ - 'json' => $request->jsonSerialize() - ]); - - $data = json_decode($response->getBody()->getContents(), true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new RuntimeException('Failed to decode JSON response: ' . json_last_error_msg()); - } - - return SessionLockResponse::fromArray($data); - } catch (GuzzleException $e) { - throw new RuntimeException('Failed to lock/unlock session: ' . $e->getMessage(), 0, $e); - } + return SessionLockResponse::fromArray($this->post('/sessions/lock', $request)); } /** * Retrieves information for a specific session. - * + * * @param SessionInfoRequest $request The request containing session ID * @return SessionInfoResponse The response containing session information - * @throws RuntimeException If the request fails + * @throws AlgebrakitApiException If the request fails */ public function getSessionInfo(SessionInfoRequest $request): SessionInfoResponse { - try { - $response = $this->httpClient->post('/session/info', [ - 'json' => $request->jsonSerialize() - ]); - - $data = json_decode($response->getBody()->getContents(), true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new RuntimeException('Failed to decode JSON response: ' . json_last_error_msg()); - } - - return SessionInfoResponse::fromArray($data); - } catch (GuzzleException $e) { - throw new RuntimeException('Failed to get session info: ' . $e->getMessage(), 0, $e); - } + return SessionInfoResponse::fromArray($this->post('/session/info', $request)); } /** * Retrieves existing sessions by their IDs. - * + * * @param SessionRetrieveRequest $request The request containing session IDs * @return SessionRetrieveResponse The response containing session data - * @throws RuntimeException If the request fails + * @throws AlgebrakitApiException If the request fails */ public function retrieveSessions(SessionRetrieveRequest $request): SessionRetrieveResponse { - try { - $response = $this->httpClient->post('/session/retrieve', [ - 'json' => $request->jsonSerialize() - ]); - - $data = json_decode($response->getBody()->getContents(), true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new RuntimeException('Failed to decode JSON response: ' . json_last_error_msg()); - } - - return SessionRetrieveResponse::fromArray($data); - } catch (GuzzleException $e) { - throw new RuntimeException('Failed to retrieve sessions: ' . $e->getMessage(), 0, $e); - } + return SessionRetrieveResponse::fromArray($this->post('/session/retrieve', $request)); } /** * Validates an exercise. - * - * @param ExerciseValidateRequest $request The request containing exercise details + * + * @param ExerciseValidateRequest $request The exercise ID or specification to validate * @return ExerciseValidateResponse The response containing validation results - * @throws RuntimeException If the request fails + * @throws AlgebrakitApiException If the request fails */ public function validateExercise(ExerciseValidateRequest $request): ExerciseValidateResponse { - try { - $response = $this->httpClient->post('/exercise/validate', [ - 'json' => $request->jsonSerialize() - ]); - - $data = json_decode($response->getBody()->getContents(), true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new RuntimeException('Failed to decode JSON response: ' . json_last_error_msg()); - } - - return ExerciseValidateResponse::fromArray($data); - } catch (GuzzleException $e) { - throw new RuntimeException('Failed to validate exercise: ' . $e->getMessage(), 0, $e); - } + return ExerciseValidateResponse::fromArray($this->post('/exercise/validate', $request)); } /** * Retrieves published information about an exercise. - * + * * @param ExerciseInfoRequest $request The request containing exercise ID * @return ExerciseInfoResponse The response containing exercise information - * @throws RuntimeException If the request fails + * @throws AlgebrakitApiException If the request fails */ public function getExerciseInfo(ExerciseInfoRequest $request): ExerciseInfoResponse + { + return ExerciseInfoResponse::fromArray($this->post('/exercise/published-info', $request)); + } + + /** + * Sends a request to the API and returns the decoded JSON response. + * + * @throws AlgebrakitApiException If the request fails or the response is not valid JSON + */ + private function post(string $path, JsonSerializable $request): array { try { - $response = $this->httpClient->post('/exercise/published-info', [ - 'json' => $request->jsonSerialize() - ]); - - $data = json_decode($response->getBody()->getContents(), true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new RuntimeException('Failed to decode JSON response: ' . json_last_error_msg()); + $response = $this->httpClient->post($path, ['json' => $request->jsonSerialize()]); + } catch (RequestException $e) { + if ($e->hasResponse()) { + $status = $e->getResponse()->getStatusCode(); + $message = self::errorMessage((string) $e->getResponse()->getBody()); + throw new AlgebrakitApiException("Algebrakit API error $status: $message", $status, $e); } - - return ExerciseInfoResponse::fromArray($data); + throw new AlgebrakitApiException('Algebrakit API request failed: ' . $e->getMessage(), null, $e); } catch (GuzzleException $e) { - throw new RuntimeException('Failed to get exercise info: ' . $e->getMessage(), 0, $e); + throw new AlgebrakitApiException('Algebrakit API request failed: ' . $e->getMessage(), null, $e); + } + + $status = $response->getStatusCode(); + $body = (string) $response->getBody(); + if ($status >= 400) { + // Only reached with a custom client that has 'http_errors' => false + throw new AlgebrakitApiException("Algebrakit API error $status: " . self::errorMessage($body), $status); + } + + $data = json_decode($body, true); + if (!is_array($data)) { + throw new AlgebrakitApiException('Could not decode the Algebrakit API response: ' . json_last_error_msg(), $status); + } + return $data; + } + + /** + * Extracts the error message from an API error response body, + * for example {"success":false,"error":"Could not validate credentials: ..."}. + */ + private static function errorMessage(string $body): string + { + $data = json_decode($body, true); + if (is_array($data)) { + foreach (['error', 'msg', 'message'] as $key) { + if (isset($data[$key]) && is_string($data[$key])) { + return $data[$key]; + } + } } + return $body !== '' ? $body : 'no response body'; } -} \ No newline at end of file +} diff --git a/tests/SessionServiceTest.php b/tests/SessionServiceTest.php new file mode 100644 index 0000000..4e4f5e7 --- /dev/null +++ b/tests/SessionServiceTest.php @@ -0,0 +1,150 @@ +history. */ + private function service(Response|ConnectException ...$responses): SessionService + { + $stack = HandlerStack::create(new MockHandler($responses)); + $stack->push(Middleware::history($this->history)); + return new SessionService('test-key', new Client(['handler' => $stack, 'base_uri' => 'https://api.test'])); + } + + private static function json(mixed $body, int $status = 200): Response + { + return new Response($status, ['Content-Type' => 'application/json'], json_encode($body)); + } + + private static function httpClientOf(SessionService $service): Client + { + return (new ReflectionProperty(SessionService::class, 'httpClient'))->getValue($service); + } + + public function testUsesTheProductionApiAndSendsTheKeyByDefault(): void + { + $client = self::httpClientOf(new SessionService('my-key')); + + $this->assertSame('https://api.algebrakit.com', (string) $client->getConfig('base_uri')); + $this->assertSame('my-key', $client->getConfig('headers')['X-API-KEY']); + $this->assertSame(30.0, $client->getConfig('timeout')); + } + + public function testBaseUrlAndTimeoutCanBeSetWithNamedArguments(): void + { + $client = self::httpClientOf(new SessionService('key', baseUrl: 'https://prod.api.sg-1.algebrakit.com', timeout: 5)); + + $this->assertSame('https://prod.api.sg-1.algebrakit.com', (string) $client->getConfig('base_uri')); + $this->assertSame(5.0, $client->getConfig('timeout')); + } + + public function testCallsTheRightEndpointForEachMethod(): void + { + $service = $this->service( + self::json([['success' => true, 'sessions' => []]]), + self::json([]), + self::json([]), + self::json([]), + self::json([]), + self::json(['valid' => true]), + self::json([]), + ); + + $service->createSession(new CreateSessionRequest(exercises: [new ExerciseById('e1')])); + $service->getSessionScore(new SessionScoreRequest('s1')); + $service->lockOrUnlockSession(new SessionLockRequest('LOCK', ['s1'])); + $service->getSessionInfo(new SessionInfoRequest('s1')); + $service->retrieveSessions(new SessionRetrieveRequest(['s1'])); + $service->validateExercise(new ExerciseValidateRequest(exerciseId: 'e1')); + $service->getExerciseInfo(new ExerciseInfoRequest('e1')); + + $this->assertSame( + [ + 'POST /session/create', + 'POST /session/score', + 'POST /sessions/lock', // 1.0.0 used /session/lock, which doesn't exist + 'POST /session/info', + 'POST /session/retrieve', + 'POST /exercise/validate', + 'POST /exercise/published-info', + ], + array_map(fn (array $t) => $t['request']->getMethod() . ' ' . $t['request']->getUri()->getPath(), $this->history) + ); + } + + public function testCreateSessionSendsApiVersion2(): void + { + $this->service(self::json([['success' => true, 'sessions' => []]])) + ->createSession(new CreateSessionRequest(exercises: [new ExerciseById('e1')])); + + $body = json_decode((string) $this->history[0]['request']->getBody(), true); + $this->assertSame(2, $body['apiVersion']); + $this->assertSame([['exerciseId' => 'e1', 'version' => 'latest']], $body['exercises']); + } + + public function testErrorsIncludeTheApiMessageAndStatus(): void + { + $service = $this->service(self::json( + ['success' => false, 'error' => 'Could not validate credentials: the provided api-key is not valid'], + 401 + )); + + try { + $service->getSessionInfo(new SessionInfoRequest('s1')); + $this->fail('Expected an AlgebrakitApiException'); + } catch (AlgebrakitApiException $e) { + $this->assertSame( + 'Algebrakit API error 401: Could not validate credentials: the provided api-key is not valid', + $e->getMessage() + ); + $this->assertSame(401, $e->getStatusCode()); + $this->assertInstanceOf(RuntimeException::class, $e); // existing catch (RuntimeException) blocks keep working + } + } + + public function testNetworkErrorsHaveNoStatusCode(): void + { + $service = $this->service(new ConnectException('Connection refused', new Request('POST', '/session/info'))); + + try { + $service->getSessionInfo(new SessionInfoRequest('s1')); + $this->fail('Expected an AlgebrakitApiException'); + } catch (AlgebrakitApiException $e) { + $this->assertSame('Algebrakit API request failed: Connection refused', $e->getMessage()); + $this->assertNull($e->getStatusCode()); + } + } + + public function testInvalidJsonIsReported(): void + { + $this->expectException(AlgebrakitApiException::class); + $this->expectExceptionMessage('Could not decode the Algebrakit API response'); + + $this->service(new Response(200, [], 'not json'))->getSessionInfo(new SessionInfoRequest('s1')); + } +}