diff --git a/api/README.md b/api/README.md index 75bdd61486f..871173fab28 100644 --- a/api/README.md +++ b/api/README.md @@ -41,8 +41,8 @@ services: The application can also be installed manually, although this variant has only undergone limited testing. Prerequisites are a working installation of PHP 8 and [composer](https://getcomposer.org/): -- Copy the content of this repository to your target server. Only the `./api/public` directory should be publicly accessible. -- Install the required dependencies by performing `composer install` inside the `./api/` directory. +- Copy the content of this repository to your target server. Only the `./api/public` directory should be publicly accessible. +- Install the required dependencies by performing `composer install` inside the `./api/` directory. - Copy `./api/config_sample.txt_` into a file `./api/config.php` and adapt to your needs. - Access the api via the `api/public/index.php` file. @@ -81,25 +81,25 @@ The response is again a JSON document, with the following fields: - an int field `questionseed` indicating the seed used for this response - an int array `questionvariants` containing all variant seeds of the question - an array of arrays `iframes` of arguments to create iframes to hold JS panels e.g. JSXGraph, GeoGebra -- a boolean field `isinteractive`, indicating if the question contains elements preventing a static representation. If true, a printed version of the question would make no sense -- a string field `questionnote`, containing the rendered questionnote of the question -- an object field `aboutapi`, containing version metadata for the API and bundled Maxima instance - -The `aboutapi` object contains: - -- `stackmaxima`: The configured STACK Maxima version. -- `stackapi`: The configured API version identifier. - -The input configuration consists of the following fields: +- a boolean field `isinteractive`, indicating if the question contains elements preventing a static representation. If true, a printed version of the question would make no sense +- a string field `questionnote`, containing the rendered questionnote of the question +- an object field `aboutapi`, containing version metadata for the API and bundled Maxima instance + +The `aboutapi` object contains: + +- `stackmaxima`: The configured STACK Maxima version. +- `stackapi`: The configured API version identifier. + +The input configuration consists of the following fields: - `validationtype`: A number indicating the configured validation type of the input. Possible values are 0 (hidden), 1 (with variable list), 2 (without variable list) and 3 (compact) -- `samplesolution`: A map from strings to strings, containing the model answer of the input, in its input form. Input types which are rendered as only one input field, contain only an empty string as key, which is mapped to the model answer. More complex input types contain multiple entries, corresponding to different sub inputs, e.g. for matrix entries, or checkboxes. +- `samplesolution`: A map from strings to strings, containing the model answer of the input, in its input form. Input types which are rendered as only one input field, contain only an empty string as key, which is mapped to the model answer. More complex input types contain multiple entries, corresponding to different sub inputs, e.g. for matrix entries, or checkboxes. - `samplesolutionrender`: The rendered model answer, as latex code. - `configuration`: A map of configuration options. See below. #### Input Configuration Keys -The following keys can be contained inside the input configuration options. The availability depends on the type of the input. Please consult the STACK documentation to check which options are supported by which types, if availability is not explicitly specified below: +The following keys can be contained inside the input configuration options. The availability depends on the type of the input. Please consult the STACK documentation to check which options are supported by which types, if availability is not explicitly specified below: - `type`: Indicates the type of the input, e.g. `algebraic`. Present for all inputs. Possible values are: `algebraic`, `boolean`, `checkbox`, `dropdown`, `equiv`, `matrix`, `notes`, `numerical`, `radio`, `singlechar`, `string`, `textarea`, `units` and `varmatrix`. - `boxWidth`: Specifies the desired box size of the input. @@ -132,7 +132,7 @@ The grading route returns the following fields: - a map from the PRT names to floats `scoreweights`, containing the weighting for each part. `scoreweights['total']` contains the default total mark for the question. The mark for a question part is its `score[prt] * scoreweights[prt] * scoreweights['total']`. - a string field `specificfeedback` containing the rendered specific feedback text - a map from the PRT names to strings `prts`, containing the rendered PRT feedback -- a map from the PRT names to objects `prtresults`, containing structured PRT grading data. Each entry includes `score`, `penalty`, `answernotes` (the full answer-note path as an array), `prtanswernotes` (the PRT-node answer notes only), `errors`, and `fverrors`. +- a map from the PRT names to objects `prtresults`, containing structured PRT grading data. Each entry includes `score`, `penalty`, `answernotes` (the full answer-note path as an array), `prtanswernotes` (the PRT-node answer notes only), `errors`, and `fverrors`. - a string map `gradingassets`, containing a list of assets used in the grading response, see [Plots/Assets](#plotsassets) - a string field `responsesummary` containing a summary of response. (See [Reporting](../doc/en/Authoring/../STACK_question_admin/Reporting.md).) - an array of arrays `iframes` of arguments to create iframes to hold JS panels e.g. JSXGraph, GeoGebra @@ -153,7 +153,7 @@ The validation route returns a string field `Validation` with the corresponding The `POST /download` route is used to download files created by questions. - `questionDefinition`: The Moodle-XML-Export of a single STACK question. -- `seed`: Seed to choose a question variant. Must be contained in the list of deployed variants. If +- `seed`: Seed to choose a question variant. Must be contained in the list of deployed variants. If no seed is provided, the first deployed variant is used. - `filename` - as specified in the question definition and included in the question render. - `fileid` - as specified by the question render. @@ -182,13 +182,13 @@ In the `results` object, each seed will key an object: - int|null: `passes`: Number of tests passed. - int|null: `fails`: Number of tests failed. - string: `messages`: Seed level error message. Includes summaries from the test, runtime errors and general feedback errors. -- object: `outcomes`: Gives detailed breakdown of test result. Entries keyed by ``. +- object: `outcomes`: Gives detailed breakdown of test result. Entries keyed by ``. In the outcomes object, each test will key an object: - boolean: `passed`: Did the test pass? - string: `reason`: Reason for failure. A test empty message or the part of the output (e.g. score) which doesn't match the expected result. - object: `inputs`: Keyed by input name. Details of the inputs and their values. -- object: `outcomes`: Keyed by PRT name. Details of the outcomes and expected outcomes for each PRT. Each PRT outcome includes the legacy delimiter-separated `answernote` string, plus structured `answernotes` and `prtanswernotes` arrays. +- object: `outcomes`: Keyed by PRT name. Details of the outcomes and expected outcomes for each PRT. Each PRT outcome includes the legacy delimiter-separated `answernote` string, plus structured `answernotes` and `prtanswernotes` arrays. Example result object: ``` @@ -216,8 +216,8 @@ Example result object: "score": 1, "penalty": 0, "answernote": "prt1-1-T", - "answernotes": ["prt1-1-T"], - "prtanswernotes": ["prt1-1-T"], + "answernotes": ["prt1-1-T"], + "prtanswernotes": ["prt1-1-T"], "expectedscore": 1, "expectedpenalty": 0, "expectedanswernote": "prt1-1-T", @@ -273,7 +273,7 @@ prt: **Under development** - Defaults, how they're handled and YAML layout may change. The API accepts questions is a Moodle XML format i.e. ``. Missing fields will be filled -in from the `questiondefaults.yml` file. If there are no inputs or PRTs, a single one of each will be created. There will be no tests by default. +in from the `questiondefaults.yml` file. If there are no inputs or PRTs, a single one of each will be created. There will be no tests by default. - Input: `name: ans1, type: algebraic, tans: ta1` - PRT: `name: prt1` @@ -281,12 +281,12 @@ in from the `questiondefaults.yml` file. If there are no inputs or PRTs, a singl If the API does not find XML in the required format it will attempt to interpret the file as YAML, again filling in blanks from the default file as with the XML. Fields are slightly different than from XML - rather than some fields having `text` and `format` children, there are -`field` and `fieldformat` fields e.g. +`field` and `fieldformat` fields e.g. ```

[[feedback:prt1]]

html -
` +` ``` becomes ``` @@ -338,7 +338,7 @@ If using summary format in the default file, do so like this: The API returns rendered CASText as parts of its responses in multiple places. The CASText is output as a single string in an intermediate format, which cannot be directly fed to browsers for display, and requires further processing. Applications using the API have to handle the following cases: - **Latex**: The rendered CASText can contain Latex code, which must be rendered before being displayed to the user, e.g. by MathJax. Latex blocks are always enclosed by either `\[ \]` for display mode latex, or `\( \)` for inline mode. -- **Substitution Tokens**: The rendered CASText can contain substitution tokens, indicating where inputs, input validations or PRT feedback should be inserted. These tokens have the format `[[type:name]]`, where type can be either `feedback`, `input` or `validation`, and name corresponds to the input or PRT name. It is up to the embedding application to replace these tokens with the appropriate content, depending on the context. +- **Substitution Tokens**: The rendered CASText can contain substitution tokens, indicating where inputs, input validations or PRT feedback should be inserted. These tokens have the format `[[type:name]]`, where type can be either `feedback`, `input` or `validation`, and name corresponds to the input or PRT name. It is up to the embedding application to replace these tokens with the appropriate content, depending on the context. - **Images**: The rendered CASText can contain image tags, which have to be processed as described below: [Plots/Assets](#plotsassets) @@ -383,7 +383,7 @@ Code dependencies of the api implementation are managed using composer. At runti ### Docker based development setup -To ease the development process, the Dockerfile contained in the repository contains multiple stages for development, profiling and production deployment. To start developing using a docker container, start the docker-compose stack defined in the file [docker-compose.dev.yml](/api/docker/docker-compose.dev.yml). E.g. +To ease the development process, the Dockerfile contained in the repository contains multiple stages for development, profiling and production deployment. To start developing using a docker container, start the docker-compose stack defined in the file [docker-compose.dev.yml](/api/docker/docker-compose.dev.yml). E.g. docker compose -f docker-compose.dev.yml build docker compose -f docker-compose.dev.yml up @@ -435,6 +435,54 @@ A basic frontend is provided at `http://localhost:3080/stack.php`. This should a `http://localhost:3080/bulktest.php` provides a front end for selecting a folder of STACK question files and running their included tests, similar to the STACK bulk test functionality in Moodle. +### Private API demo + +The private API demo in `api/private-demo` is a demonstration of serving questions without exposing their Moodle XML to the browser. The browser sends either a `questionId` from a generated manifest, or a `questionPath` relative to the configured question library. These values are resolved to the local XML on the server side and then forwarded as STACK API requests to a private API container. The intermediate server also proxies generated plot/static assets back to the browser. + +The production-style compose file exposes only the frontend container on port 80: + + cd api/docker + docker compose -f docker-compose.demo-private.yml build + docker compose -f docker-compose.demo-private.yml up + +After startup the demo is available at `http://localhost/`. The API container is reachable only inside the Docker network; the frontend container calls it using `STACK_PRIVATE_DEMO_API_URL`, which defaults to `http://api`. + +The question library location is configured with the `STACK_PRIVATE_DEMO_LIBRARY` environment variable. It defaults to `stacklibrary`, meaning `samplequestions/stacklibrary`. Set it to another directory name under `samplequestions` in the compose file, for example `stackdemo`, to serve `samplequestions/stackdemo` instead. After changing this value, rebuild `question-manifest.json` so the public catalogue and opaque question ids match the selected library. + +Pages: + +`notes.php` - An example of a course notes web page with embedded STACK questions. + +`index.php` - Allows a search of the library and display of any question. + +For local development, use the development compose file: + + cd api/docker + docker compose -f docker-compose.demo-private.dev.yml build + docker compose -f docker-compose.demo-private.dev.yml up + +The development stack exposes the frontend at `http://localhost:3081/`, bind-mounts the relevant source folders, and rebuilds `api/private-demo/public/assets/question-manifest.json` on startup if it is missing. To rebuild the manifest manually after changing the configured STACK question library, run: + + php api/private-demo/build_manifest.php + +The demo exposes these frontend routes: + +- `GET /demo/questions`: returns the public question catalogue. Each entry includes `questionId`, `name`, `filename`, and `category`; the XML path is not included. +- `GET /embed?questionId=`: renders a single embeddable question frame using a manifest id. +- `GET /embed?questionPath=`: renders a single embeddable question frame using a question-library-relative XML path. +- `POST /demo/render`, `POST /demo/validate`, `POST /demo/grade`, `POST /demo/download`, and `POST /demo/diff`: proxy the corresponding STACK API route. Requests must include exactly one of `questionId` or `questionPath`; `questionDefinition` is rejected by the demo frontend. +- `GET /demo/plot.php/`: proxies generated plots and static question assets from the private API container. +- `GET /cors.php?name=` and `GET /demo/cors.php?name=`: serve CORS helper assets needed by iframe content. + +To set up your own notes site, things you need to do include: + +- Add questions in a folder in `samplequestions`. +- Set `STACK_PRIVATE_DEMO_LIBRARY` in Docker compose file. +- Build the manifest by running Docker compose. That will create question ids. +- Create pages similar to notes.php in the public folder. +- Update home.php page to link to your notes. +- If you don't want your questions searchable or the question manifest publicly available, comment out the `/demo/questions` route in `index.php`. + ### Modifications of existing STACK code The implementation of the standalone api required some modifications to existing STACK code, which could cause issues with future upstream patches. All performed modifications are documented in this section. diff --git a/api/docker/DockerfilePrivateDemo b/api/docker/DockerfilePrivateDemo new file mode 100644 index 00000000000..325a89ceae6 --- /dev/null +++ b/api/docker/DockerfilePrivateDemo @@ -0,0 +1,34 @@ +# This Dockerfile builds a public frontend for the private STACK API demo. +# It serves the browser UI and privately calls an API container over the +# Docker network. It does not expose the STACK API routes directly. + +FROM composer:lts AS composer + +COPY / /work/ + +WORKDIR /work/api/ + +RUN composer install +RUN composer dump-autoload --optimize + +FROM php:8.4.5-apache-bookworm + +RUN a2enmod rewrite + +ENV APACHE_DOCUMENT_ROOT=/srv/stack/api/private-demo/public +ENV STACK_PRIVATE_DEMO_API_URL=http://api +ENV STACK_PRIVATE_DEMO_LIBRARY=stacklibrary + +RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf +RUN sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf + +COPY ./api/docker/files/private-demo/000-default.conf /etc/apache2/sites-available/000-default.conf +COPY / /srv/stack +COPY --from=composer /work/api/vendor /srv/stack/api/vendor + +RUN mkdir -p /srv/stack/api/private-demo/public/assets && \ + cp /srv/stack/api/public/api.css /srv/stack/api/private-demo/public/assets/api.css && \ + cp /srv/stack/api/public/stackjsvle.js /srv/stack/api/private-demo/public/assets/stackjsvle.js && \ + cp /srv/stack/api/public/stackshared.js /srv/stack/api/private-demo/public/assets/stackshared.js && \ + cp /srv/stack/api/public/logo_large.png /srv/stack/api/private-demo/public/assets/logo_large.png && \ + cp /srv/stack/api/public/favicon.ico /srv/stack/api/private-demo/public/favicon.ico diff --git a/api/docker/docker-compose.demo-private.dev.yml b/api/docker/docker-compose.demo-private.dev.yml new file mode 100644 index 00000000000..3141d2006f2 --- /dev/null +++ b/api/docker/docker-compose.demo-private.dev.yml @@ -0,0 +1,58 @@ +# Development version of the private STACK API demo. +# +# Only the frontend is exposed to the host. Source is bind-mounted so PHP, +# JavaScript, CSS, samplequestions, and API changes are visible without rebuilding. +services: + maxima: + image: mathinstitut/goemaxima:2026062900-latest + tmpfs: + - "/tmp" + restart: unless-stopped + cap_add: + - SETGID + - SETUID + cap_drop: + - ALL + environment: + GOEMAXIMA_QUEUE_LEN: 32 + read_only: true + + api: + build: + context: ./../../ + dockerfile: ./api/docker/Dockerfile + target: development + restart: unless-stopped + expose: + - "80" + volumes: + - ../../:/srv/stack + - /srv/stack/api/vendor/ + depends_on: + - maxima + + frontend: + build: + context: ./../../ + dockerfile: ./api/docker/DockerfilePrivateDemo + restart: unless-stopped + environment: + STACK_PRIVATE_DEMO_API_URL: http://api + STACK_PRIVATE_DEMO_LIBRARY: stacklibrary + command: > + sh -c "test -f /srv/stack/api/private-demo/assets/question-manifest.json || + php /srv/stack/api/private-demo/build_manifest.php; + apache2-foreground" + volumes: + - ../../api/private-demo:/srv/stack/api/private-demo + - ../../api/public/api.css:/srv/stack/api/private-demo/public/assets/api.css:ro + - ../../api/public/stackjsvle.js:/srv/stack/api/private-demo/public/assets/stackjsvle.js:ro + - ../../api/public/stackshared.js:/srv/stack/api/private-demo/public/assets/stackshared.js:ro + - ../../api/public/logo_large.png:/srv/stack/api/private-demo/public/assets/logo_large.png:ro + - ../../api/public/favicon.ico:/srv/stack/api/private-demo/public/favicon.ico:ro + - ../../samplequestions:/srv/stack/samplequestions:ro + - ../../corsscripts:/srv/stack/corsscripts:ro + ports: + - "3081:80" + depends_on: + - api diff --git a/api/docker/docker-compose.demo-private.yml b/api/docker/docker-compose.demo-private.yml new file mode 100644 index 00000000000..9ee1717b493 --- /dev/null +++ b/api/docker/docker-compose.demo-private.yml @@ -0,0 +1,37 @@ +# This demo exposes only the frontend container. The browser sends question ids +# to the frontend, and the frontend privately sends question XML to the API. +services: + maxima: + image: mathinstitut/goemaxima:2026062900-latest + tmpfs: + - "/tmp" + restart: unless-stopped + cap_add: + - SETGID + - SETUID + cap_drop: + - ALL + environment: + GOEMAXIMA_QUEUE_LEN: 32 + read_only: true + + api: + image: stackmaths/stackapi:2026062900-latest + restart: unless-stopped + expose: + - "80" + depends_on: + - maxima + + frontend: + build: + context: ./../../ + dockerfile: ./api/docker/DockerfilePrivateDemo + restart: unless-stopped + environment: + STACK_PRIVATE_DEMO_API_URL: http://api + STACK_PRIVATE_DEMO_LIBRARY: stacklibrary + ports: + - "80:80" + depends_on: + - api diff --git a/api/docker/files/private-demo/000-default.conf b/api/docker/files/private-demo/000-default.conf new file mode 100644 index 00000000000..119bb36c1b4 --- /dev/null +++ b/api/docker/files/private-demo/000-default.conf @@ -0,0 +1,13 @@ + + ServerAdmin webmaster@localhost + DocumentRoot ${APACHE_DOCUMENT_ROOT} + + + Options FollowSymLinks + AllowOverride All + Require all granted + + + ErrorLog /dev/stderr + TransferLog /dev/stdout + diff --git a/api/private-demo/.gitignore b/api/private-demo/.gitignore new file mode 100644 index 00000000000..30b51c238a5 --- /dev/null +++ b/api/private-demo/.gitignore @@ -0,0 +1 @@ +question-manifest.json diff --git a/api/private-demo/assets/.gitignore b/api/private-demo/assets/.gitignore new file mode 100644 index 00000000000..d6b7ef32c84 --- /dev/null +++ b/api/private-demo/assets/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/api/private-demo/build_manifest.php b/api/private-demo/build_manifest.php new file mode 100644 index 00000000000..6982a41f2c5 --- /dev/null +++ b/api/private-demo/build_manifest.php @@ -0,0 +1,141 @@ +. + +/** + * Build the private demo question manifest. + * + * The browser receives opaque question ids from this manifest. Only the + * frontend container maps those ids back to question-library XML files. + * + * @package qtype_stack + * @copyright 2026 University of Edinburgh + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. + */ +defined('MOODLE_INTERNAL') || die(); +require_once(__DIR__ . '/lib.php'); + +$root = STACK_PRIVATE_DEMO_LIBRARY_ROOT; +$output = __DIR__ . '/assets/question-manifest.json'; +$libraryroot = 'samplequestions/' . getenv('STACK_PRIVATE_DEMO_LIBRARY'); + +if ($root === false || !is_dir($root)) { + fwrite(STDERR, "Could not find $libraryroot.\n"); + exit(1); +} + +$files = []; +$iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS) +); + +foreach ($iterator as $file) { + if ($file->isFile() && strtolower($file->getExtension()) === 'xml') { + $files[] = $file->getPathname(); + } +} +sort($files, SORT_STRING); + +$questions = []; +$errors = []; +$skipped = [ + 'nostack' => 0, + 'multiple' => 0, +]; + +foreach ($files as $file) { + $relativepath = ltrim(str_replace('\\', '/', substr($file, strlen($root))), '/'); + $xml = file_get_contents($file); + + libxml_use_internal_errors(true); + $quiz = simplexml_load_string($xml); + $xmlerrors = libxml_get_errors(); + libxml_clear_errors(); + + if ($quiz === false) { + $errors[] = $relativepath . ': invalid XML'; + continue; + } + + $stackquestions = []; + foreach ($quiz->question as $question) { + $type = (string) $question['type']; + if ($type === 'category') { + continue; + } + if ($type === 'stack') { + $stackquestions[] = $question; + } + } + + if (count($stackquestions) === 0) { + // The library contains category metadata and Moodle question types that + // are not STACK API questions. They are not part of this demo catalogue. + $skipped['nostack']++; + continue; + } + + if (count($stackquestions) !== 1) { + // This demo maps one public id to one library file. Multi-question + // bundles can stay in the library, but are skipped here. + $skipped['multiple']++; + continue; + } + + $name = trim((string) $stackquestions[0]->name->text); + if ($name === '') { + $name = basename($relativepath, '.xml'); + } + + $id = 'q_' . substr(hash('sha256', $relativepath), 0, 16); + if (array_key_exists($id, $questions)) { + $errors[] = $relativepath . ': duplicate generated question id ' . $id; + continue; + } + + $category = dirname($relativepath); + $questions[$id] = [ + 'id' => $id, + 'name' => $name, + 'filename' => basename($relativepath), + 'path' => $relativepath, + 'category' => $category === '.' ? '' : $category, + ]; +} + +if ($errors) { + foreach ($errors as $error) { + fwrite(STDERR, $error . "\n"); + } +} + +$manifest = [ + 'root' => $libraryroot, + 'questions' => $questions, +]; + +$json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); +if ($json === false) { + fwrite(STDERR, "Could not encode manifest JSON.\n"); + exit(1); +} + +file_put_contents($output, $json . "\n"); +fwrite(STDOUT, 'Wrote ' . count($questions) . " questions to $output\n"); +fwrite( + STDOUT, + 'Skipped ' . $skipped['nostack'] . ' XML files without STACK questions and ' . + $skipped['multiple'] . " multi-question XML files.\n" +); diff --git a/api/private-demo/embed.php b/api/private-demo/embed.php new file mode 100644 index 00000000000..4fd0ad11437 --- /dev/null +++ b/api/private-demo/embed.php @@ -0,0 +1,113 @@ +. + +/** + * Embeddable single-question frame for the private demo. + * + * @package qtype_stack + * @copyright 2026 University of Edinburgh + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. + */ + +defined('MOODLE_INTERNAL') || die(); +require_once(__DIR__ . '/lib.php'); + +$reference = stack_private_demo_question_reference($queryparams ?? []); +$xml = stack_private_demo_question_definition_from_reference($reference); +libxml_use_internal_errors(true); +$quiz = simplexml_load_string($xml); +libxml_clear_errors(); +$seedsequence = stack_private_demo_embed_seed_sequence($queryparams ?? [], $quiz); + +$title = 'Practice question'; + +if ($quiz !== false && isset($quiz->question[0]->name->text)) { + $questionname = trim((string) $quiz->question[0]->name->text); + if ($questionname !== '') { + $title = $questionname; + } +} + +$question = array_merge(['name' => $title], $reference); +if ($seedsequence !== null) { + $question['seeds'] = $seedsequence; +} +?> + + + + + <?=htmlspecialchars($question['name'])?> + + + + + + + + + + + + +
+

+
+ +
+ + +
+ + diff --git a/api/private-demo/home.php b/api/private-demo/home.php new file mode 100644 index 00000000000..1f3fe21446f --- /dev/null +++ b/api/private-demo/home.php @@ -0,0 +1,123 @@ +. + +/** + * Embeddable single-question frame for the private demo. + * + * @package qtype_stack + * @copyright 2026 University of Edinburgh + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. + */ + +require_once('../config.php'); +require_once(__DIR__ . '../../emulation/MoodleEmulation.php'); +// Required to pass Moodle code check. Uses emulation stub. +require_login(); +?> + + + + + STACK Private API Demo + + + + + + + +
+ +

Private API Demonstration

+
+

+ This demo serves questions from the STACK library without sending question XML to the browser. + You can search the library below. There is also a sample of a + page with teaching notes and embedded questions. +

+
+
+
+
+ + +
+
+
+
+
+
+ +
+
+
+

+ The STACK source code, including this API, is licensed under the GNU General Public, License Version 3. + Documentation, sample questions and materials, are licensed under + Creative Commons Attribution-ShareAlike 4.0 International. +

+
+
+
+
+ + diff --git a/api/private-demo/lib.php b/api/private-demo/lib.php new file mode 100644 index 00000000000..b504d6616a6 --- /dev/null +++ b/api/private-demo/lib.php @@ -0,0 +1,468 @@ +. + +/** + * Shared helpers for the private STACK API demo frontend. + * + * @package qtype_stack + * @copyright 2026 University of Edinburgh + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. + */ + +define('STACK_PRIVATE_DEMO_ROOT', realpath(__DIR__ . '/../..')); +define( + 'STACK_PRIVATE_DEMO_LIBRARY_ROOT', + realpath(STACK_PRIVATE_DEMO_ROOT . '/samplequestions/' . trim(getenv('STACK_PRIVATE_DEMO_LIBRARY') ?: 'stacklibrary', '/')) +); +define('STACK_PRIVATE_DEMO_CORS_ROOT', realpath(STACK_PRIVATE_DEMO_ROOT . '/corsscripts')); +define('STACK_PRIVATE_DEMO_MANIFEST', __DIR__ . '/assets/question-manifest.json'); +define('STACK_PRIVATE_DEMO_API_BASE', rtrim(getenv('STACK_PRIVATE_DEMO_API_URL') ?: 'http://api', '/')); + +/** + * HTTP error for the private demo frontend. + */ +class stack_private_demo_http_exception extends RuntimeException { + /** + * Get the HTTP status. + * + * @return int HTTP status. + */ + public function get_status() { + return $this->getCode() ?: 500; + } +} + +/** + * Raise an HTTP error. + * + * @param string $message Error message. + * @param int $status HTTP status. + * @throws stack_private_demo_http_exception + */ +function stack_private_demo_error($message, $status = 400) { + throw new stack_private_demo_http_exception($message, $status); +} + +/** + * Write a JSON response. + * + * @param object $response Response. + * @param mixed $data Response data. + * @param int $status HTTP status. + * @return object Response. + */ +function stack_private_demo_json_response($response, $data, $status = 200) { + $json = json_encode($data, JSON_UNESCAPED_SLASHES); + if ($json === false) { + stack_private_demo_error('Could not encode JSON response.', 500); + } + + $response->getBody()->write($json); + return $response + ->withStatus($status) + ->withHeader('Content-Type', 'application/json;charset=UTF-8'); +} + +/** + * Write a text response. + * + * @param object $response Response. + * @param string $text Response text. + * @param int $status HTTP status. + * @return object Response. + */ +function stack_private_demo_text_response($response, $text, $status = 200) { + $response->getBody()->write($text); + return $response + ->withStatus($status) + ->withHeader('Content-Type', 'text/plain;charset=UTF-8'); +} + +/** + * Render a local PHP template into a response. + * + * @param object $response Response. + * @param string $template Template path. + * @param array $data Template data. + * @return object Response. + */ +function stack_private_demo_template_response($response, $template, $data = []) { + $queryparams = $data['queryparams'] ?? null; + + ob_start(); + require($template); + $response->getBody()->write(ob_get_clean()); + return $response->withHeader('Content-Type', 'text/html;charset=UTF-8'); +} + +/** + * Resolve a relative path beneath a configured root directory. + * + * @param string|false $root Root directory. + * @param string $relativepath Relative path. + * @return string|false Resolved file path, or false if unavailable. + */ +function stack_private_demo_resolve_file($root, $relativepath) { + if (!is_string($root) || $root === '' || !is_dir($root) || !is_string($relativepath) || $relativepath === '') { + return false; + } + + $fullpath = realpath($root . DIRECTORY_SEPARATOR . $relativepath); + if ($fullpath === false || !str_starts_with($fullpath, $root . DIRECTORY_SEPARATOR) || !is_file($fullpath)) { + return false; + } + + return $fullpath; +} + +/** + * Load the question manifest checked into the repository. + * + * @return array Manifest. + */ +function stack_private_demo_manifest() { + if (!file_exists(STACK_PRIVATE_DEMO_MANIFEST)) { + stack_private_demo_error('Question manifest is not available.', 500); + } + + $manifest = json_decode(file_get_contents(STACK_PRIVATE_DEMO_MANIFEST), true); + if (!is_array($manifest) || !isset($manifest['questions']) || !is_array($manifest['questions'])) { + stack_private_demo_error('Question manifest is invalid.', 500); + } + + return $manifest; +} + +/** + * Return the public catalogue exposed to the browser. + * + * @return array Catalogue. + */ +function stack_private_demo_catalogue() { + $questions = array_values(stack_private_demo_manifest()['questions']); + usort($questions, function ($left, $right) { + return [$left['category'], $left['name']] <=> [$right['category'], $right['name']]; + }); + + return array_map(function ($question) { + return [ + 'questionId' => $question['id'], + 'name' => $question['name'], + 'filename' => $question['filename'], + 'category' => $question['category'], + ]; + }, $questions); +} + +/** + * Decode a JSON request body. + * + * @param object $request Request. + * @return array Request data. + */ +function stack_private_demo_request_json($request) { + $data = json_decode((string) $request->getBody(), true); + if (!is_array($data)) { + stack_private_demo_error('Expected a JSON object.'); + } + if (array_key_exists('questionDefinition', $data)) { + stack_private_demo_error('questionDefinition is not accepted by this demo.'); + } + + return $data; +} + +/** + * Get exactly one question reference from request data. + * + * @param array $data Request data. + * @return array Question reference. + */ +function stack_private_demo_question_reference($data) { + $questionid = $data['questionId'] ?? null; + $questionid = is_string($questionid) ? trim($questionid) : null; + $questionid = $questionid === '' ? null : $questionid; + + $questionpath = $data['questionPath'] ?? null; + $questionpath = is_string($questionpath) ? trim($questionpath) : null; + $questionpath = $questionpath === '' ? null : $questionpath; + + if (($questionid === null && $questionpath === null) || ($questionid !== null && $questionpath !== null)) { + stack_private_demo_error('Exactly one of questionId or questionPath is required.'); + } + + if ($questionid !== null) { + return ['questionId' => $questionid]; + } + return ['questionPath' => $questionpath]; +} + +/** + * Resolve a question id to a local XML question definition. + * + * @param string $questionid Opaque question id. + * @return string XML question definition. + */ +function stack_private_demo_question_definition($questionid) { + $manifest = stack_private_demo_manifest(); + if (!isset($manifest['questions'][$questionid])) { + stack_private_demo_error('Unknown question id.', 404); + } + + return stack_private_demo_question_definition_from_path($manifest['questions'][$questionid]['path'], true); +} + +/** + * Resolve a question reference to a local XML question definition. + * + * @param array $reference Question reference. + * @return string XML question definition. + */ +function stack_private_demo_question_definition_from_reference($reference) { + return isset($reference['questionId']) ? + stack_private_demo_question_definition($reference['questionId']) : + stack_private_demo_question_definition_from_path($reference['questionPath']); +} + +/** + * Get the seed sequence requested for an embedded question. + * + * @param array $data Query parameters. + * @param SimpleXMLElement $quiz Quiz XML. + * @return int[]|null Seed sequence, or null if no seed control was requested. + */ +function stack_private_demo_embed_seed_sequence($data, $quiz) { + if (empty($data['seeds']) || !is_string($data['seeds'])) { + return null; + } + + $seeds = trim($data['seeds']); + if (strtolower($seeds) === 'all') { + return stack_private_demo_deployed_seeds_from_xml($quiz); + } + + return array_map('intval', preg_split('/\s*,\s*/', $seeds, -1, PREG_SPLIT_NO_EMPTY)); +} + +/** + * Read deployed seeds from question XML. + * + * @param SimpleXMLElement $quiz . + * @return int[] Deployed seeds. + */ +function stack_private_demo_deployed_seeds_from_xml($quiz) { + $seeds = []; + foreach ($quiz->question[0]->deployedseed as $seed) { + $seeds[] = (int) $seed; + } + return $seeds; +} + +/** + * Resolve a question-library-relative path to a local XML question definition. + * + * @param string $relativepath Relative path under the configured question library. + * @param bool $manifestpath Whether the path came from the generated manifest. + * @return string XML question definition. + */ +function stack_private_demo_question_definition_from_path($relativepath, $manifestpath = false) { + if ( + !is_string($relativepath) || + $relativepath === '' || + str_starts_with($relativepath, '/') || + strtolower(pathinfo($relativepath, PATHINFO_EXTENSION)) !== 'xml' + ) { + stack_private_demo_error($manifestpath ? 'Invalid question path in manifest.' : 'Invalid question path.', 400); + } + + $fullpath = stack_private_demo_resolve_file(STACK_PRIVATE_DEMO_LIBRARY_ROOT, $relativepath); + if ($fullpath === false) { + stack_private_demo_error('Question file is not available.', $manifestpath ? 500 : 404); + } + + $xml = file_get_contents($fullpath); + if ($xml === false) { + stack_private_demo_error('Question file could not be read.', 500); + } + + return $xml; +} + +/** + * Build an API request from a browser request. + * + * @param array $data Browser request. + * @param string $route API route. + * @return array API request. + */ +function stack_private_demo_api_payload($data, $route) { + $reference = stack_private_demo_question_reference($data); + $payload = $data; + unset($payload['questionId']); + unset($payload['questionPath']); + $payload['questionDefinition'] = stack_private_demo_question_definition_from_reference($reference); + + if ($route === 'render') { + unset($payload['answers']); + } + + return $payload; +} + +/** + * Proxy a private STACK API container response. + * + * @param object $response Response. + * @param string $url API URL. + * @param array $options HTTP stream options. + * @param string $defaulttype Default content type. + * @param string $errormessage Message to show if the request fails. + * @param bool $forwardlength Whether to forward the content length header. + * @return object Response. + */ +function stack_private_demo_proxy_api($response, $url, $options, $defaulttype, $errormessage, $forwardlength = false) { + $context = stream_context_create(['http' => $options + [ + 'ignore_errors' => true, + 'timeout' => 60, + ]]); + $body = file_get_contents($url, false, $context); + if ($body === false) { + stack_private_demo_error($errormessage, 502); + } + + $status = 200; + $contenttype = $defaulttype; + $length = ''; + foreach ($http_response_header ?? [] as $header) { + if (preg_match('/^HTTP\/\S+\s+([0-9]{3})\s/', $header, $matches)) { + $status = (int) $matches[1]; + continue; + } + if (stripos($header, 'Content-Type:') === 0) { + $contenttype = trim(substr($header, strlen('Content-Type:'))); + continue; + } + if (stripos($header, 'Content-Length:') === 0) { + $length = trim(substr($header, strlen('Content-Length:'))); + } + } + + $response = $response + ->withStatus($status) + ->withHeader('Content-Type', $contenttype); + + if ($forwardlength && $length !== '') { + $response = $response->withHeader('Content-Length', $length); + } + + $response->getBody()->write($body); + return $response; +} + +/** + * Proxy a JSON POST to the private STACK API container. + * + * @param object $request Request. + * @param object $response Response. + * @param string $route API route. + * @param array $payload API payload. + * @return object Response. + */ +function stack_private_demo_proxy_json($request, $response, $route, $payload) { + $json = json_encode($payload, JSON_UNESCAPED_SLASHES); + if ($json === false) { + stack_private_demo_error('Could not encode API request.', 500); + } + + $headers = ['Content-Type: application/json']; + $language = $request->getHeaderLine('Accept-Language'); + if ($language !== '') { + $headers[] = 'Accept-Language: ' . $language; + } + + return stack_private_demo_proxy_api( + $response, + STACK_PRIVATE_DEMO_API_BASE . '/' . $route, + [ + 'method' => 'POST', + 'header' => implode("\r\n", $headers), + 'content' => $json, + ], + 'application/json', + 'Private API request failed.' + ); +} + +/** + * Proxy a generated plot/static question asset from the private API container. + * + * @param object $response Response. + * @param string $filename Asset filename. + * @return object Response. + */ +function stack_private_demo_proxy_plot($response, $filename) { + $filename = basename(urldecode($filename)); + if ($filename === '' || $filename === '.' || $filename === '..') { + stack_private_demo_error('Invalid asset name.'); + } + + return stack_private_demo_proxy_api( + $response, + STACK_PRIVATE_DEMO_API_BASE . '/plot.php/' . rawurlencode($filename), + ['method' => 'GET'], + 'application/octet-stream', + 'Private API asset request failed.', + true + ); +} + +/** + * Serve a static helper asset with CORS headers for iframe content. + * + * @param object $request Request. + * @param object $response Response. + * @param string $asset Relative asset path. + * @return object Response. + */ +function stack_private_demo_cors_asset($request, $response, $asset) { + $response = $response + ->withHeader('Access-Control-Allow-Origin', '*') + ->withHeader('Access-Control-Allow-Headers', '*'); + + if ($request->getMethod() === 'OPTIONS') { + return $response + ->withStatus(204) + ->withHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + } + + $fullpath = stack_private_demo_resolve_file(STACK_PRIVATE_DEMO_CORS_ROOT, ltrim(urldecode($asset), '/')); + if ($fullpath === false) { + return stack_private_demo_text_response($response, 'No such script here.', 404); + } + + $types = [ + 'css' => 'text/css;charset=UTF-8', + 'js' => 'text/javascript;charset=UTF-8', + 'map' => 'application/json;charset=UTF-8', + 'json' => 'application/json;charset=UTF-8', + ]; + + $response->getBody()->write(file_get_contents($fullpath)); + return $response + ->withHeader('Content-Type', $types[strtolower(pathinfo($fullpath, PATHINFO_EXTENSION))] ?? + 'application/octet-stream') + ->withHeader('Cache-Control', 'public, max-age=31104000, immutable'); +} diff --git a/api/private-demo/public/.htaccess b/api/private-demo/public/.htaccess new file mode 100644 index 00000000000..66ef8f69ce3 --- /dev/null +++ b/api/private-demo/public/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] diff --git a/api/private-demo/public/assets/.gitignore b/api/private-demo/public/assets/.gitignore new file mode 100644 index 00000000000..d6b7ef32c84 --- /dev/null +++ b/api/private-demo/public/assets/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/api/private-demo/public/embed-question.js b/api/private-demo/public/embed-question.js new file mode 100644 index 00000000000..b3792d559bf --- /dev/null +++ b/api/private-demo/public/embed-question.js @@ -0,0 +1,61 @@ +// This file is part of Stack - http://stack.maths.ed.ac.uk/ +// +// Stack is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +let embeddedQuestion = {}; +let questions = [{name: 'Practice question'}]; +let page = 0; +let seed = null; +let seedIndex = 0; +let seedSequence = []; + +function configureEmbeddedQuestion(question) { + embeddedQuestion = question || {}; + questions = [{name: embeddedQuestion.name || 'Practice question'}]; + seedSequence = Array.isArray(embeddedQuestion.seeds) ? embeddedQuestion.seeds : []; + seedIndex = 0; + seed = seedSequence.length > 0 ? seedSequence[0] : null; +} + +$(document).ready(function () { + document.getElementById('stackapi_variant').style.display = seedSequence.length > 1 ? '' : 'none'; + send(); +}); + +function collectData() { + const data = { + answers: collectAnswer(), + seed: seed, + renderInputs: inputPrefix, + }; + if (embeddedQuestion.questionId) { + data.questionId = embeddedQuestion.questionId; + } else { + data.questionPath = embeddedQuestion.questionPath; + } + return data; +} + +function advanceVariant() { + if (seedSequence.length < 2) { + return; + } + seedIndex = (seedIndex + 1) % seedSequence.length; + seed = seedSequence[seedIndex]; + send(); +} + +function toggleAnswer(button) { + const element = document.getElementById('stackapi_correct'); + const status = element.style.display; + if (status === 'block') { + element.style.display = 'none'; + button.value = 'Display Correct Answers'; + } else { + element.style.display = 'block'; + button.value = 'Hide Correct Answers'; + } +} diff --git a/api/private-demo/public/index.php b/api/private-demo/public/index.php new file mode 100644 index 00000000000..bdeb2c1f132 --- /dev/null +++ b/api/private-demo/public/index.php @@ -0,0 +1,104 @@ +. + +/** + * Private STACK API demo frontend. + * + * @package qtype_stack + * @copyright 2026 University of Edinburgh + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. + */ + +require_once('../config.php'); +require_once(__DIR__ . '../../emulation/MoodleEmulation.php'); +// Required to pass Moodle code check. Uses emulation stub. +require_login(); +require_once(__DIR__ . '/../lib.php'); +require_once(__DIR__ . '/../../vendor/autoload.php'); + +use Psr\Http\Message\ResponseInterface; +use Slim\Factory\AppFactory; + +$app = AppFactory::create(); +$responsefactory = $app->getResponseFactory(); + +$app->add(function ($request, $handler) use ($responsefactory) { + try { + return $handler->handle($request); + } catch (stack_private_demo_http_exception $exception) { + $response = $responsefactory->createResponse(); + if (str_starts_with($request->getUri()->getPath(), '/demo/')) { + return stack_private_demo_json_response( + $response, + ['message' => $exception->getMessage()], + $exception->get_status() + ); + } + return stack_private_demo_text_response($response, $exception->getMessage(), $exception->get_status()); + } +}); + +// NB: This essentially allows access to all your questions. +// It's used in the current home page with question search. +$app->get('/demo/questions', function ($request, ResponseInterface $response) { + return stack_private_demo_json_response($response, stack_private_demo_catalogue()); +}); + +foreach (['/cors.php', '/demo/cors.php'] as $route) { + $app->map(['GET', 'OPTIONS'], $route, function ($request, ResponseInterface $response) { + $params = $request->getQueryParams(); + return stack_private_demo_cors_asset($request, $response, $params['name'] ?? ''); + }); +} + +$app->get('/demo/plot.php/{filename:.+}', function ($request, ResponseInterface $response, $args) { + return stack_private_demo_proxy_plot($response, $args['filename']); +}); + +$app->post('/demo/{route:render|validate|grade|download|diff}', function ($request, ResponseInterface $response, $args) { + $route = $args['route']; + $data = stack_private_demo_request_json($request); + $payload = stack_private_demo_api_payload($data, $route); + if ($route === 'diff') { + $payload = ['questionDefinition' => $payload['questionDefinition']]; + } + return stack_private_demo_proxy_json($request, $response, $route, $payload); +}); + +$app->get('/embed', function ($request, ResponseInterface $response) { + return stack_private_demo_template_response( + $response, + __DIR__ . '/../embed.php', + ['queryparams' => $request->getQueryParams()] + ); +}); + +foreach (['/', '/index.php'] as $route) { + $app->get($route, function ($request, ResponseInterface $response) { + return stack_private_demo_template_response($response, __DIR__ . '/../home.php'); + }); +} + +$app->map(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], '/{routes:.+}', function ($request, ResponseInterface $response) { + if (str_starts_with($request->getUri()->getPath(), '/demo/')) { + return stack_private_demo_json_response($response, ['message' => 'Not found'], 404); + } + return $response + ->withStatus(302) + ->withHeader('Location', '/index.php'); +}); + +$app->run(); diff --git a/api/private-demo/public/notes.php b/api/private-demo/public/notes.php new file mode 100644 index 00000000000..ffe80f07c01 --- /dev/null +++ b/api/private-demo/public/notes.php @@ -0,0 +1,126 @@ +. + +/** + * Sample maths notes page with embedded private-demo STACK questions. + * + * @package qtype_stack + * @copyright 2026 University of Edinburgh + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. + */ + +require_once('../config.php'); +require_once(__DIR__ . '../../emulation/MoodleEmulation.php'); +// Required to pass Moodle code check. Uses emulation stub. +require_login(); +$pathquestion = 'Algebra-Refresher/01-Combinations-of-arithmetic-operations/AlgMap-1-1.xml'; +$pathquestionseeds = 'all'; +$idquestion = 'q_ae2a68e432c6de1b'; +$idquestionseeds = '1239251428, 505204816'; +?> + + + + + Sample Maths Notes + + + + +
+ Back +

Integer Arithmetic: Sample Notes

+

+ These notes illustrate how a resource author can combine explanatory material with embedded STACK questions. +

+ +
+

Adding And Subtracting Integers

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer non arcu vel justo tincidunt + congue. Suspendisse potenti. Curabitur vitae lectus sed lorem fermentum cursus. Donec id + risus at turpis dictum tincidunt. +

+

+ When working with signed numbers, it is useful to keep track of the operation and the sign of + each number separately. Praesent commodo, sem at pulvinar laoreet, ipsum neque hendrerit + lectus, sed posuere risus neque at augue. +

+
+ +
+

Embedded By File Location

+

+ This embedded question is selected by a configured question-library-relative file location: + . +

+ +
+ +
+

Embedded By Question ID

+

+ This embedded question is selected by the generated manifest id: + . +

+ +
+ +
+

Further Notes

+

+ Mauris rhoncus, nunc at tincidunt vulputate, augue arcu tempor enim, ac vestibulum nisl + risus non libero. Sed et massa sed lorem dictum facilisis. Aliquam erat volutpat. +

+
+
+ + diff --git a/api/private-demo/public/private-demo.js b/api/private-demo/public/private-demo.js new file mode 100644 index 00000000000..d3b044a385b --- /dev/null +++ b/api/private-demo/public/private-demo.js @@ -0,0 +1,68 @@ +// This file is part of Stack - http://stack.maths.ed.ac.uk/ +// +// Stack is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +const maxVisibleQuestions = 100; +let questions = []; +let page = 0; + +$(document).ready(function () { + fetch('demo/questions') + .then((response) => response.json()) + .then((catalogue) => { + questions = catalogue; + document.getElementById('question-search').addEventListener('input', renderQuestionList); + document.getElementById('question-list').addEventListener('change', (event) => { + goToPage(Number(event.target.value)); + }); + renderQuestionList(); + if (questions.length > 0) { + goToPage(0); + } + }) + .catch(() => { + document.getElementById('errors').innerText = 'There was an error loading the question list.'; + }); +}); + +function renderQuestionList() { + const list = document.getElementById('question-list'); + const count = document.getElementById('question-count'); + const search = document.getElementById('question-search').value.trim().toLowerCase(); + const matches = questions + .map((question, index) => ({question, index})) + .filter(({question}) => { + if (search === '') { + return true; + } + return question.name.toLowerCase().includes(search) || + question.filename.toLowerCase().includes(search); + }); + const visibleMatches = matches.slice(0, maxVisibleQuestions); + + list.innerHTML = ''; + for (const {question, index} of visibleMatches) { + const option = document.createElement('option'); + option.value = index; + option.text = `${question.name} - ${question.filename}`; + option.title = question.category; + if (index === page) { + option.selected = true; + } + list.appendChild(option); + } + + count.innerText = visibleMatches.length === matches.length ? + `${matches.length} matching questions` : + `Showing ${visibleMatches.length} of ${matches.length} matching questions`; +} + +function goToPage(targetPage) { + page = targetPage; + document.getElementById('question-list').value = String(page); + document.getElementById('question-frame').src = + `/embed?questionId=${encodeURIComponent(questions[page].questionId)}`; +} diff --git a/api/public/stackshared.js b/api/public/stackshared.js index 7c8a307d7ee..fe72744d764 100644 --- a/api/public/stackshared.js +++ b/api/public/stackshared.js @@ -27,11 +27,12 @@ const feedbackPrefix = 'stackapi_fb_'; const validationPrefix = 'stackapi_val_'; const FULLDISPLAY = 'FULL'; const SAMPLEDISPLAY = 'SAMPLE'; +const stackApiDemoConfig = window.stackApiDemoConfig || {}; // Set API server here with trailing slash e.g. 'https://stack-api.maths.ed.ac.uk/'. // Leave as is if page is on the same server e.g. for STACK API demo. -const serverUrl = '/'; -const requestLanguage = 'en'; -let displayType = FULLDISPLAY; +const serverUrl = stackApiDemoConfig.serverUrl || '/'; +const requestLanguage = stackApiDemoConfig.requestLanguage || 'en'; +let displayType = stackApiDemoConfig.displayType || FULLDISPLAY; // Get the different input elements by tag and return object with values. function collectAnswer() { @@ -366,7 +367,7 @@ function answer() { function renameIframeHolders() { // Each call to STACK restarts numbering of iframe holders so we need to rename // any old ones to make sure new iframes end up in the correct place. - for (const iframe of document.querySelectorAll(`[id^=stack-iframe-holder]:not([id$=old]`)) { + for (const iframe of document.querySelectorAll('[id^=stack-iframe-holder]:not([id$=old])')) { iframe.id = iframe.id + '_old'; } } diff --git a/tests/api_private_lib_test.php b/tests/api_private_lib_test.php new file mode 100644 index 00000000000..a1eccb6afcb --- /dev/null +++ b/tests/api_private_lib_test.php @@ -0,0 +1,203 @@ +. + +/** + * Unit tests for private demo helpers. + * + * @package qtype_stack + * @copyright 2026 University of Edinburgh + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later. + * phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses + */ + +namespace qtype_stack; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../api/private-demo/lib.php'); + +/** + * Minimal response body double. + */ +class api_private_demo_test_body { + /** @var string Written body content. */ + public $contents = ''; + + // phpcs:ignore moodle.Commenting.MissingDocblock.MissingTestcaseMethodDescription + public function write($text): void { + $this->contents .= $text; + } +} + +/** + * Minimal response double. + */ +class api_private_demo_test_response { + /** @var int HTTP status. */ + public $status = 200; + + /** @var array HTTP headers. */ + public $headers = []; + + /** @var api_private_demo_test_body Response body. */ + private $body; + + // phpcs:ignore moodle.Commenting.MissingDocblock.MissingTestcaseMethodDescription + public function __construct() { + $this->body = new api_private_demo_test_body(); + } + + // phpcs:ignore moodle.Commenting.MissingDocblock.MissingTestcaseMethodDescription,moodle.NamingConventions.ValidFunctionName.LowercaseMethod + public function getBody() { + return $this->body; + } + + // phpcs:ignore moodle.Commenting.MissingDocblock.MissingTestcaseMethodDescription,moodle.NamingConventions.ValidFunctionName.LowercaseMethod + public function withStatus($status) { + $clone = clone $this; + $clone->status = $status; + return $clone; + } + + // phpcs:ignore moodle.Commenting.MissingDocblock.MissingTestcaseMethodDescription,moodle.NamingConventions.ValidFunctionName.LowercaseMethod + public function withHeader($name, $value) { + $clone = clone $this; + $clone->headers[$name] = $value; + return $clone; + } +} + +/** + * Minimal request double. + */ +class api_private_demo_test_request { + /** @var string Request body. */ + private $body; + + // phpcs:ignore moodle.Commenting.MissingDocblock.MissingTestcaseMethodDescription + public function __construct($body) { + $this->body = $body; + } + + // phpcs:ignore moodle.Commenting.MissingDocblock.MissingTestcaseMethodDescription,moodle.NamingConventions.ValidFunctionName.LowercaseMethod + public function getBody() { + return $this->body; + } +} + +/** + * Unit tests for private demo helpers. + * + * @group qtype_stack + * @covers \qtype_stack + */ +final class api_private_lib_test extends \advanced_testcase { + public function test_json_and_text_responses_write_body_status_and_content_type(): void { + $jsonresponse = \stack_private_demo_json_response(new api_private_demo_test_response(), ['ok' => true], 201); + $this->assertSame(201, $jsonresponse->status); + $this->assertSame('application/json;charset=UTF-8', $jsonresponse->headers['Content-Type']); + $this->assertSame('{"ok":true}', $jsonresponse->getBody()->contents); + + $textresponse = \stack_private_demo_text_response(new api_private_demo_test_response(), 'plain text', 202); + $this->assertSame(202, $textresponse->status); + $this->assertSame('text/plain;charset=UTF-8', $textresponse->headers['Content-Type']); + $this->assertSame('plain text', $textresponse->getBody()->contents); + } + + public function test_resolve_file_accepts_only_files_beneath_root(): void { + $parent = __DIR__; + + $this->assertSame( + realpath($parent . '/api_private_lib_test.php'), + \stack_private_demo_resolve_file($parent, '/api_private_lib_test.php') + ); + $this->assertFalse(\stack_private_demo_resolve_file($parent, '../version.php')); + $this->assertFalse(\stack_private_demo_resolve_file($parent, 'sub')); + $this->assertFalse(\stack_private_demo_resolve_file(false, '/api_private_lib_test.php')); + } + + public function test_question_reference_requires_exactly_one_identifier(): void { + $this->assertSame(['questionId' => 'q_123'], \stack_private_demo_question_reference([ + 'questionId' => ' q_123 ', + ])); + $this->assertSame(['questionPath' => 'topic/question.xml'], \stack_private_demo_question_reference([ + 'questionPath' => ' topic/question.xml ', + ])); + + try { + \stack_private_demo_question_reference([]); + $this->fail('Expected an exception for a missing question reference.'); + } catch (\stack_private_demo_http_exception $exception) { + $this->assertSame(400, $exception->get_status()); + $this->assertSame('Exactly one of questionId or questionPath is required.', $exception->getMessage()); + } + + try { + \stack_private_demo_question_reference(['questionId' => 'q_123', 'questionPath' => 'topic/question.xml']); + $this->fail('Expected an exception for two question references.'); + } catch (\stack_private_demo_http_exception $exception) { + $this->assertSame(400, $exception->get_status()); + } + } + + public function test_request_json_rejects_invalid_json_and_question_definition(): void { + $this->assertSame(['questionId' => 'q_123'], \stack_private_demo_request_json( + new api_private_demo_test_request('{"questionId":"q_123"}') + )); + + try { + \stack_private_demo_request_json(new api_private_demo_test_request('not json')); + $this->fail('Expected an exception for invalid JSON.'); + } catch (\stack_private_demo_http_exception $exception) { + $this->assertSame('Expected a JSON object.', $exception->getMessage()); + } + + try { + \stack_private_demo_request_json(new api_private_demo_test_request('{"questionDefinition":""}')); + $this->fail('Expected an exception for questionDefinition.'); + } catch (\stack_private_demo_http_exception $exception) { + $this->assertSame('questionDefinition is not accepted by this demo.', $exception->getMessage()); + } + } + + public function test_embed_seed_sequence_uses_all_deployed_seeds_or_comma_list(): void { + $quiz = simplexml_load_string( + '1122' + ); + + $this->assertNull(\stack_private_demo_embed_seed_sequence([], $quiz)); + $this->assertSame([11, 22], \stack_private_demo_embed_seed_sequence(['seeds' => 'all'], $quiz)); + $this->assertSame([3, 5, 8], \stack_private_demo_embed_seed_sequence(['seeds' => '3, 5,8'], $quiz)); + } + + public function test_question_definition_from_path_rejects_invalid_and_missing_paths(): void { + try { + \stack_private_demo_question_definition_from_path('/absolute.xml'); + $this->fail('Expected an exception for an absolute path.'); + } catch (\stack_private_demo_http_exception $exception) { + $this->assertSame(400, $exception->get_status()); + $this->assertSame('Invalid question path.', $exception->getMessage()); + } + + try { + \stack_private_demo_question_definition_from_path('missing.xml'); + $this->fail('Expected an exception for a missing path.'); + } catch (\stack_private_demo_http_exception $exception) { + $this->assertSame(404, $exception->get_status()); + $this->assertSame('Question file is not available.', $exception->getMessage()); + } + } +} diff --git a/tests/jest/api.private.demo.test.js b/tests/jest/api.private.demo.test.js new file mode 100644 index 00000000000..5c1f7fc0a1e --- /dev/null +++ b/tests/jest/api.private.demo.test.js @@ -0,0 +1,146 @@ +/** @jest-environment jsdom */ + +const fs = require('fs'); +const path = require('path'); + +function loadPrivateDemoModule() { + const modulePath = path.resolve(__dirname, '../../api/private-demo/public/private-demo.js'); + const source = fs.readFileSync(modulePath, 'utf8'); + const readyHandlers = []; + const wrapped = new Function('readyHandlers', ` + const $ = () => ({ready: (handler) => readyHandlers.push(handler)}); + ${source} + return { + readyHandlers, + renderQuestionList, + goToPage, + setQuestions(value) { + questions = value; + }, + getQuestions() { + return questions; + }, + getPage() { + return page; + }, + }; + `); + return wrapped(readyHandlers); +} + +function setupDom() { + document.body.innerHTML = ` + + +
+ +
+ `; +} + +function question(overrides) { + return { + questionId: 'q_default', + name: 'Default question', + filename: 'default.xml', + category: 'Default', + ...overrides, + }; +} + +async function flushPromises() { + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } +} + +describe('api/private-demo/public/private-demo.js', () => { + let privateDemo; + + beforeEach(() => { + jest.restoreAllMocks(); + setupDom(); + privateDemo = loadPrivateDemoModule(); + }); + + test('renderQuestionList filters by name and filename case-insensitively', () => { + privateDemo.setQuestions([ + question({questionId: 'q_alpha', name: 'Algebra warmup', filename: 'algebra.xml', category: 'A'}), + question({questionId: 'q_beta', name: 'Calculus', filename: 'derivative-demo.xml', category: 'B'}), + question({questionId: 'q_gamma', name: 'Geometry', filename: 'triangles.xml', category: 'C'}), + ]); + document.getElementById('question-search').value = 'DERIVATIVE'; + + privateDemo.renderQuestionList(); + + const options = [...document.querySelectorAll('#question-list option')]; + expect(options).toHaveLength(1); + expect(options[0].value).toBe('1'); + expect(options[0].text).toBe('Calculus - derivative-demo.xml'); + expect(options[0].title).toBe('B'); + expect(document.getElementById('question-count').innerText).toBe('1 matching questions'); + }); + + test('renderQuestionList limits long catalogues to the visible maximum', () => { + privateDemo.setQuestions(Array.from({length: 101}, (_, index) => question({ + questionId: `q_${index}`, + name: `Question ${index}`, + filename: `question-${index}.xml`, + }))); + + privateDemo.renderQuestionList(); + + expect(document.querySelectorAll('#question-list option')).toHaveLength(100); + expect(document.getElementById('question-count').innerText) + .toBe('Showing 100 of 101 matching questions'); + }); + + test('goToPage selects the list value and URL-encodes the question id', () => { + privateDemo.setQuestions([ + question({questionId: 'q_alpha'}), + question({questionId: 'q value/with spaces'}), + ]); + privateDemo.renderQuestionList(); + + privateDemo.goToPage(1); + + expect(privateDemo.getPage()).toBe(1); + expect(document.getElementById('question-list').value).toBe('1'); + expect(document.getElementById('question-frame').getAttribute('src')) + .toBe('/embed?questionId=q%20value%2Fwith%20spaces'); + }); + + test('ready handler fetches catalogue, wires controls, renders, and opens the first question', async () => { + const catalogue = [ + question({questionId: 'q_first', name: 'First', filename: 'first.xml'}), + question({questionId: 'q_second', name: 'Second', filename: 'second.xml'}), + ]; + global.fetch = jest.fn(() => Promise.resolve({ + json: () => Promise.resolve(catalogue), + })); + + privateDemo.readyHandlers[0](); + await flushPromises(); + + expect(global.fetch).toHaveBeenCalledWith('demo/questions'); + expect(privateDemo.getQuestions()).toEqual(catalogue); + expect(document.querySelectorAll('#question-list option')).toHaveLength(2); + expect(document.getElementById('question-frame').getAttribute('src')) + .toBe('/embed?questionId=q_first'); + + document.getElementById('question-list').value = '1'; + document.getElementById('question-list').dispatchEvent(new Event('change')); + expect(document.getElementById('question-frame').getAttribute('src')) + .toBe('/embed?questionId=q_second'); + }); + + test('ready handler displays a loading error when the catalogue request fails', async () => { + global.fetch = jest.fn(() => Promise.reject(new Error('network'))); + + privateDemo.readyHandlers[0](); + await flushPromises(); + + expect(document.getElementById('errors').innerText) + .toBe('There was an error loading the question list.'); + }); +});