From 44d9f1f3711782c9467b8d7aa9aa12cc663bc8d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C3=96st=C3=B6r?= Date: Sat, 27 Dec 2025 20:46:12 +0000 Subject: [PATCH 01/10] Bump dev image to debian trixie which is used on prod --- .devcontainer/Dockerfile | 82 ++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6c9be79..54ceccc 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,53 +1,53 @@ -# Use the official Dev Container base image for Debian Bookworm +# Use the official Dev Container base image for Debian Trixie # We use this instead of plain Debian to get a better development experience out of the box # as it includes common tools and configurations for development. -FROM mcr.microsoft.com/devcontainers/base:bookworm AS base +FROM mcr.microsoft.com/devcontainers/base:trixie AS base LABEL org.opencontainers.image.description="Development Container for Swindon Makerspace Access System" # libparse-debianchangelog-perl \ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends \ - perl \ - cpanminus \ - build-essential \ - libalgorithm-diff-perl \ - libalgorithm-diff-xs-perl \ - libalgorithm-merge-perl \ - libcgi-fast-perl \ - libcgi-pm-perl \ - libclass-accessor-perl \ - libclass-isa-perl \ - libencode-locale-perl \ - libfcgi-perl \ - libfile-fcntllock-perl \ - libhtml-parser-perl \ - libhtml-tagset-perl \ - libhttp-date-perl \ - libhttp-message-perl \ - libio-html-perl \ - libio-string-perl \ - liblocale-gettext-perl \ - liblwp-mediatypes-perl \ - libsub-name-perl \ - libswitch-perl \ - libtext-charwidth-perl \ - libtext-iconv-perl \ - libtext-wrapi18n-perl \ - libtimedate-perl \ - liburi-perl \ - libscalar-list-utils-perl \ + perl \ + cpanminus \ + build-essential \ + libalgorithm-diff-perl \ + libalgorithm-diff-xs-perl \ + libalgorithm-merge-perl \ + libcgi-fast-perl \ + libcgi-pm-perl \ + libclass-accessor-perl \ + libclass-isa-perl \ + libencode-locale-perl \ + libfcgi-perl \ + libfile-fcntllock-perl \ + libhtml-parser-perl \ + libhtml-tagset-perl \ + libhttp-date-perl \ + libhttp-message-perl \ + libio-html-perl \ + libio-string-perl \ + liblocale-gettext-perl \ + liblwp-mediatypes-perl \ + libsub-name-perl \ + libswitch-perl \ + libtext-charwidth-perl \ + libtext-iconv-perl \ + libtext-wrapi18n-perl \ + libtimedate-perl \ + liburi-perl \ + libscalar-list-utils-perl \ && cpanm -S Carton \ -# for Perl::LanguageServer + # for Perl::LanguageServer && apt-get -y install --no-install-recommends \ - libanyevent-perl \ - libclass-refresh-perl \ - libdata-dump-perl \ - libio-aio-perl \ - libjson-perl \ - libmoose-perl \ - libpadwalker-perl \ - libscalar-list-utils-perl \ - libcoro-perl \ + libanyevent-perl \ + libclass-refresh-perl \ + libdata-dump-perl \ + libio-aio-perl \ + libjson-perl \ + libmoose-perl \ + libpadwalker-perl \ + libscalar-list-utils-perl \ + libcoro-perl \ && cpanm Perl::LanguageServer \ && rm -rf /var/lib/apt/lists/* From 4b8c1fc39a23bc5362ad2208f56ac815e1324dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C3=96st=C3=B6r?= Date: Sun, 18 Jan 2026 12:15:33 +0000 Subject: [PATCH 02/10] Add Docker build configuration and test environment fixtures for the AccessSystem API. --- .dockerignore | 54 +++++++++++ Dockerfile | 135 +++++++++++++++++++++++++++ accesssystem_api_test.conf | 53 +++++++++++ t/lib/AccessSystem/Fixtures.pm | 165 +++++++++++++++++++++++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 accesssystem_api_test.conf create mode 100644 t/lib/AccessSystem/Fixtures.pm diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0b35f24 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# Git +.git/ +.gitignore +.github/ + +# Development files +.devcontainer/ +.vscode/ +.idea/ +*.code-workspace + +# Database files (should not be in image) +db/ +*.db +*.db.bak +rapidapp_coreschema.db + +# Documentation +docs/ +*.md +NOTES.txt +Changes + +# Config backups and local configs (mount at runtime) +config.bkp/ +accesssystem_api.conf +accesssystem_api_local.conf +accesssystem_dev.conf +accesssystem.conf +keys.conf + +# Keep example configs +!*.conf.example + +# Build artifacts +Makefile +META.yml +MYMETA.* +pm_to_blib +blib/ +inc/ + +# Editor/OS files +*~ +*.bak +.DS_Store +*.swp + +# OFX bank files +ofx/ +*.ofx + +# Test output +test_db.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bc2c849 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,135 @@ +# Multi-stage Dockerfile for AccessSystem +# Stages: base -> deps -> test -> production + +# ============================================================================= +# BASE STAGE - System dependencies and Perl packages from apt +# ============================================================================= +FROM debian:trixie-slim AS base + +LABEL org.opencontainers.image.description="AccessSystem - Swindon Makerspace Access Control" + +# Install system Perl and essential apt packages +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends \ + perl \ + cpanminus \ + build-essential \ + # Database drivers + libdbd-sqlite3-perl \ + libdbd-pg-perl \ + libpq-dev \ + # Libraries for CPAN XS modules + libexpat1-dev \ + libxml2-dev \ + zlib1g-dev \ + # Core Perl modules from apt (faster than CPAN) + libalgorithm-diff-perl \ + libalgorithm-diff-xs-perl \ + libalgorithm-merge-perl \ + libcgi-fast-perl \ + libcgi-pm-perl \ + libclass-accessor-perl \ + libencode-locale-perl \ + libfcgi-perl \ + libfile-fcntllock-perl \ + libhtml-parser-perl \ + libhtml-tagset-perl \ + libhttp-date-perl \ + libhttp-message-perl \ + libio-html-perl \ + libio-string-perl \ + liblocale-gettext-perl \ + liblwp-mediatypes-perl \ + libsub-name-perl \ + libtext-charwidth-perl \ + libtext-iconv-perl \ + libtext-wrapi18n-perl \ + libtimedate-perl \ + liburi-perl \ + libscalar-list-utils-perl \ + libmoose-perl \ + libjson-perl \ + libdata-dump-perl \ + libtry-tiny-perl \ + libdatetime-perl \ + libpath-class-perl \ + libplack-perl \ + # XML::Parser from apt (avoids build issues) + libxml-parser-perl \ + # Crypt::DES from apt (fails to build from CPAN, needed by RapidApp) + libcrypt-des-perl \ + # SSL/TLS support + libssl-dev \ + libio-socket-ssl-perl \ + libnet-ssleay-perl \ + ca-certificates \ + # For Carton + && cpanm -n Carton \ + && rm -rf /var/lib/apt/lists/* /root/.cpanm + +WORKDIR /app + +# ============================================================================= +# DEPS STAGE - Install CPAN dependencies via Carton +# ============================================================================= +FROM base AS deps + +# Copy dependency files first (for better layer caching) +COPY cpanfile cpanfile.snapshot ./ + +# Create vendor directory structure (for cached install) +COPY vendor/ vendor/ + +# Install dependencies using cached mode as per README +RUN carton install --cached \ + && rm -rf /root/.cpanm + +# ============================================================================= +# TEST STAGE - For running tests +# ============================================================================= +FROM deps AS test + +# Copy application code +COPY lib/ lib/ +COPY t/ t/ +COPY root/ root/ +COPY script/ script/ +COPY sql/ sql/ + +# Copy test and example configs - test config used as local override +COPY accesssystem_api.conf.example accesssystem_api.conf +COPY accesssystem_api_test.conf accesssystem_api_local.conf + +# Copy psgi files +COPY app.psgi accesssystem.psgi ./ + +# Set environment for tests +ENV CATALYST_HOME=/app +ENV PERL5LIB=/app/local/lib/perl5:/app/lib + +# Default command runs tests +CMD ["carton", "exec", "prove", "-I", "lib", "-I", "t/lib", "-r", "t/"] + +# ============================================================================= +# PRODUCTION STAGE - Slim production image +# ============================================================================= +FROM deps AS production + +# Copy only what's needed for production +COPY lib/ lib/ +COPY root/ root/ +COPY script/ script/ +COPY app.psgi accesssystem.psgi ./ + +# Config files should be mounted at runtime +# COPY accesssystem_api.conf accesssystem_api_local.conf ./ + +# Set environment +ENV CATALYST_HOME=/app +ENV PERL5LIB=/app/local/lib/perl5:/app/lib + +# Expose default Catalyst port +EXPOSE 3000 + +# Default command runs the API server +CMD ["carton", "exec", "perl", "script/accesssystem_api_server.pl", "--port", "3000", "--host", "0.0.0.0"] diff --git a/accesssystem_api_test.conf b/accesssystem_api_test.conf new file mode 100644 index 0000000..45e060c --- /dev/null +++ b/accesssystem_api_test.conf @@ -0,0 +1,53 @@ +# Test configuration for AccessSystem API +# Used when running tests in CI or locally + + + + dsn dbi:SQLite:test_db.db + + + +# Dummy reCAPTCHA keys (will pass validation in non-production mode) + + site_key 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI + secret_key 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe + + +# Disable debug in test mode + + ignore_extensions [] + + +# Test email settings - don't actually send + + stash_key email + + content_type text/plain + charset utf-8 + + + +# Dummy OneAll settings + + subdomain test + domain test.api.oneall.com + public_key test-public-key + private_key test-private-key + + +# Cookie settings + + name access_system_test + mac_secret test-cookie-secret-for-testing-only + + + + namespace accesssystem + + +# Dummy Sendinblue/Brevo settings + + api-key dummy-test-api-key + + +base_url http://localhost:3000/accesssystem/ diff --git a/t/lib/AccessSystem/Fixtures.pm b/t/lib/AccessSystem/Fixtures.pm new file mode 100644 index 0000000..d76f1b2 --- /dev/null +++ b/t/lib/AccessSystem/Fixtures.pm @@ -0,0 +1,165 @@ +package AccessSystem::Fixtures; + +use strict; +use warnings; + +use DateTime; + +=head1 NAME + +AccessSystem::Fixtures - Test fixture helpers for AccessSystem tests + +=head1 SYNOPSIS + + use lib 't/lib'; + use AccessSystem::Fixtures; + + my $schema = AccessSystem::Schema->connect("dbi:SQLite:test.db"); + $schema->deploy(); + + # Create membership tiers + AccessSystem::Fixtures::create_tiers($schema); + + # Create a test person + my $person = AccessSystem::Fixtures::create_person($schema, + name => 'Test User', + dob => '1990-01', + ); + +=head1 DESCRIPTION + +Test fixture helpers for unit tests. Provides functions to create +test data in the database. + +=cut + +my $person_counter = 0; + +=head2 create_tiers($schema) + +Create the standard membership tiers used for testing. + +=cut + +sub create_tiers { + my ($schema) = @_; + + my @tiers = ( + { + id => 1, + name => 'Other Hackspace', + description => 'Member of another hackspace/makerspace', + price => 500, # £5 + concessions_allowed => 0, + in_use => 1, + restrictions => '{}', + }, + { + id => 2, + name => 'Standard', + description => 'Standard full membership', + price => 2500, # £25 + concessions_allowed => 1, + in_use => 1, + restrictions => '{}', + }, + { + id => 3, + name => 'Student', + description => 'Student membership (requires proof)', + price => 1250, # £12.50 + concessions_allowed => 0, + in_use => 1, + restrictions => '{}', + }, + { + id => 4, + name => 'Weekend', + description => 'Weekend access only', + price => 1500, # £15 + concessions_allowed => 1, + in_use => 1, + restrictions => '{"times":[{"from":"6:00:00","to":"7:23:59"}]}', + }, + { + id => 5, + name => "Men's Shed", + description => "Men's Shed membership", + price => 1000, # £10 + concessions_allowed => 0, + in_use => 1, + restrictions => '{}', + }, + ); + + for my $tier_data (@tiers) { + $schema->resultset('Tier')->update_or_create($tier_data); + } + + return; +} + +=head2 create_person($schema, %args) + +Create a test person. Returns the Person result object. + +Accepts optional arguments: + - name: Person name (defaults to 'Test Person N') + - email: Email address (defaults to 'test{N}@example.com') + - dob: Date of birth as 'YYYY-MM' (defaults to '1980-01') + - address: Address (defaults to '123 Test Street') + - c_rate: Concessionary rate override (e.g., 'student', 'legacy') + - tier_id: Tier ID (defaults to 2 = Standard) + - payment: Payment override (in pence) + - member_of_other_hackspace: Boolean (defaults to 0) + +=cut + +sub create_person { + my ($schema, %args) = @_; + + $person_counter++; + + my $person_data = { + name => $args{name} // "Test Person $person_counter", + email => $args{email} // "test$person_counter\@example.com", + dob => $args{dob} // '1980-01', + address => $args{address} // '123 Test Street, Testville, TE5 7ST', + tier_id => $args{tier_id} // 2, # Default to Standard tier + }; + + # Handle concessionary rate override + if (defined $args{c_rate}) { + $person_data->{concessionary_rate_override} = $args{c_rate}; + } + + # Handle payment override + if (defined $args{payment}) { + $person_data->{payment_override} = $args{payment}; + } + + my $person = $schema->resultset('Person')->create($person_data); + + return $person; +} + +=head2 reset_counter() + +Reset the person counter. Useful between test files. + +=cut + +sub reset_counter { + $person_counter = 0; + return; +} + +1; + +__END__ + +=head1 AUTHOR + +AccessSystem test fixtures + +=cut From 042bac5658aa4923f0342e99283cb2f4e8a4e5c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C3=96st=C3=B6r?= Date: Sun, 18 Jan 2026 13:08:15 +0000 Subject: [PATCH 03/10] Make all tests pass Improve person relationship handling by checking `parent_id` directly and setting `is_admin` during door access creation, and enhance test setup with schema deployment, fixture creation for 'The Door' and 'Donation' tier, and more robust tool creation. --- lib/AccessSystem/Schema/Result/Person.pm | 12 +++++++++--- t/01app.t | 25 +++++++++++++++++++++++- t/ResultSetPerson.t | 5 ++--- t/lib/AccessSystem/Fixtures.pm | 18 +++++++++++++++++ 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/lib/AccessSystem/Schema/Result/Person.pm b/lib/AccessSystem/Schema/Result/Person.pm index 80a0783..f64a8b4 100644 --- a/lib/AccessSystem/Schema/Result/Person.pm +++ b/lib/AccessSystem/Schema/Result/Person.pm @@ -265,12 +265,16 @@ sub is_valid { my $is_paid; - if(!$self->parent) { + # Check parent_id column directly to avoid relationship resolution issues + # on newly created objects that haven't been stored yet + my $parent_id = $self->get_column('parent_id'); + if(!$parent_id) { $is_paid = $self->payments_rs->search({ paid_on_date => { '<=' => $date_str }, expires_on_date => { '>=' => $date_str }, })->count; } else { + # Only call parent relationship if we have a parent_id return $self->parent->is_valid; } @@ -305,7 +309,8 @@ sub bank_ref { sub normal_dues { my ($self) = @_; - return 0 if $self->parent; + # Check parent_id column directly to avoid relationship resolution issues + return 0 if $self->get_column('parent_id'); if ($self->is_donor) { return 0; @@ -940,7 +945,8 @@ sub update_door_access { # This entry should exist, but covid policy may have removed it.. my $door = $self->result_source->schema->the_door(); - my $door_allowed = $self->allowed->find_or_create({ tool_id => $door->id }); + # is_admin required: allowed.is_admin has a NOT NULL constraint + my $door_allowed = $self->allowed->find_or_create({ tool_id => $door->id, is_admin => 0 }); $door_allowed->update({ pending_acceptance => 'false', accepted_on => DateTime->now()}); } diff --git a/t/01app.t b/t/01app.t index a824f04..25773a1 100644 --- a/t/01app.t +++ b/t/01app.t @@ -2,9 +2,32 @@ use strict; use warnings; use Test::More; +use Cwd qw(getcwd); + +# Set CATALYST_HOME so config is loaded correctly +$ENV{CATALYST_HOME} = getcwd(); + +use lib 't/lib'; +use AccessSystem::Schema; +use AccessSystem::Fixtures; + +# Use the same database that the config specifies +# The test config uses dbi:SQLite:test_db.db +my $testdb = 'test_db.db'; +unlink $testdb if -e $testdb; # Start fresh + +# Deploy schema and create fixtures +my $schema = AccessSystem::Schema->connect("dbi:SQLite:$testdb"); +$schema->deploy(); +AccessSystem::Fixtures::create_tiers($schema); use Catalyst::Test 'AccessSystem::API'; -ok( request('/register')->is_success, 'Request should succeed' ); +# Test that the app loads and responds to requests +ok( request('/login')->is_success, 'Request to /login should succeed' ); +ok( request('/register')->is_success, 'Request to /register should succeed' ); + +# Clean up +unlink($testdb); done_testing(); diff --git a/t/ResultSetPerson.t b/t/ResultSetPerson.t index 198b5ce..e1347cd 100644 --- a/t/ResultSetPerson.t +++ b/t/ResultSetPerson.t @@ -82,8 +82,8 @@ my $schema = AccessSystem::Schema->connect("dbi:SQLite:$testdb"); my $comms_count = 0; my $testee = AccessSystem::Fixtures::create_person($schema, payment => $payment_amount); $testee->create_related('tokens', { id => '12345678', type => 'test token' }); - # The Door so that Result::Person::update_door_access works - my $thing = $schema->resultset('Tool')->create({ name => 'The Door', assigned_ip => '10.0.0.1', requires_induction => 1, team => 'Who knows' }); + # The Door so that Result::Person::update_door_access works (fixtures may have already created it) + my $thing = $schema->resultset('Tool')->find_or_create({ name => 'The Door', assigned_ip => '10.0.0.1', requires_induction => 1, team => 'Who knows' }); my $allowed = $testee->create_related('allowed', { tool => $thing, is_admin => 0}); $allowed->discard_changes(); $allowed->update({ pending_acceptance => 0 }); @@ -233,4 +233,3 @@ my $schema = AccessSystem::Schema->connect("dbi:SQLite:$testdb"); done_testing; - diff --git a/t/lib/AccessSystem/Fixtures.pm b/t/lib/AccessSystem/Fixtures.pm index d76f1b2..4b358f2 100644 --- a/t/lib/AccessSystem/Fixtures.pm +++ b/t/lib/AccessSystem/Fixtures.pm @@ -90,11 +90,29 @@ sub create_tiers { in_use => 1, restrictions => '{}', }, + { + id => 6, + name => 'Donation', + description => 'Donor only membership (no access)', + price => 0, + concessions_allowed => 0, + in_use => 1, + restrictions => '{}', + }, ); for my $tier_data (@tiers) { $schema->resultset('Tier')->update_or_create($tier_data); } + + # Create 'The Door' tool - required by update_door_access() + $schema->resultset('Tool')->update_or_create({ + id => 1, + name => 'The Door', + assigned_ip => '10.0.0.1', + requires_induction => 0, + team => 'Everyone', + }); return; } From 2d136ef3d4d52198bc01f00d1bef6b630d384567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C3=96st=C3=B6r?= Date: Sun, 18 Jan 2026 13:13:56 +0000 Subject: [PATCH 04/10] Add GitHub Actions CI workflow for testing and building Docker images. --- .github/workflows/ci.yaml | 99 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..9f58a18 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,99 @@ +name: CI - Test and Build + +on: + push: + branches: + - master + tags: + - 'v*' + pull_request: + branches: + - master + workflow_dispatch: + +env: + CONTAINER_REGISTRY: ghcr.io + IMAGE_NAME: access-system + +jobs: + test: + name: 🧪 Run Tests + runs-on: ubuntu-latest + + steps: + - name: 📦 Checkout code + uses: actions/checkout@v6 + + - name: 🛠️ Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 🏗️ Build test image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + target: test + load: true + tags: accesssystem-test:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: 🧪 Run tests + run: | + docker run --rm accesssystem-test:latest + + build-production: + name: 🚀 Build Production Image + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')) + permissions: + contents: read + packages: write + + steps: + - name: 📦 Checkout code + uses: actions/checkout@v6 + + - name: 🏷️ Generate Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.CONTAINER_REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch,priority=610 + type=semver,pattern={{raw}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha,format=long + type=raw,value=latest,enable={{is_default_branch}} + annotations: | + runnumber=${{ github.run_id }} + sha=${{ github.sha }} + ref=${{ github.ref }} + org.opencontainers.image.description="Production Container for Swindon Makerspace Access System" + + - name: 🔐 Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.CONTAINER_REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 🛠️ Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 🚀 Build and push production image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + target: production + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + cache-from: type=gha + cache-to: type=gha,mode=max From 5d1022d72386881f9c9e6dae6b123d32a6069d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C3=96st=C3=B6r?= Date: Sun, 18 Jan 2026 13:57:07 +0000 Subject: [PATCH 05/10] Add a local production-like test environment using Docker Compose, including a start script, database seeding, and setup instructions. --- test-deploy/README.md | 32 ++++++++++ test-deploy/docker-compose.yaml | 37 ++++++++++++ test-deploy/seed_data.sql | 10 ++++ test-deploy/start.sh | 103 ++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 test-deploy/README.md create mode 100644 test-deploy/docker-compose.yaml create mode 100644 test-deploy/seed_data.sql create mode 100755 test-deploy/start.sh diff --git a/test-deploy/README.md b/test-deploy/README.md new file mode 100644 index 0000000..032adf7 --- /dev/null +++ b/test-deploy/README.md @@ -0,0 +1,32 @@ +# Local Production Test Environment + +This directory contains configuration and scripts to run the production Docker container locally with a full PostgreSQL database. + +## 🚀 Quick Start + +1. **Run the start script:** + ```bash + ./start.sh + ``` + This will: + - Build the production image + - Start containers (App + Postgres 17) + - Deploy the database schema + - Seed test data (Tiers + "The Door") + +2. **Access the App:** + - [http://localhost:3000/login](http://localhost:3000/login) + - [http://localhost:3000/register](http://localhost:3000/register) + +## 🛑 Stop & Cleanup + +```bash +docker compose down -v +``` + +## 📂 Structure + +- **`docker-compose.yaml`**: Orchestrates valid Prod container + Postgres DB. +- **`config/accesssystem_api_local.conf`**: Test-specific config (connects to local DB, dummy keys). +- **`seed_data.sql`**: Initial data needed for the app to function (Membership Tiers, Tools). +- **`start.sh`**: Automates build, deploy, and seed steps. diff --git a/test-deploy/docker-compose.yaml b/test-deploy/docker-compose.yaml new file mode 100644 index 0000000..b392aa5 --- /dev/null +++ b/test-deploy/docker-compose.yaml @@ -0,0 +1,37 @@ +# Temporary docker-compose for testing the production container +# Usage: docker compose -f docker-compose.test.yaml up + +services: + db: + image: postgres:17 + environment: + POSTGRES_USER: access + POSTGRES_PASSWORD: accesstest + POSTGRES_DB: accesssystem + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U access -d accesssystem" ] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: + context: .. + dockerfile: Dockerfile + target: production + ports: + - "3000:3000" + environment: + CATALYST_HOME: /app + volumes: + - ./config/accesssystem_api_local.conf:/app/accesssystem_api_local.conf:ro + - ../accesssystem_api.conf.example:/app/accesssystem_api.conf:ro + depends_on: + db: + condition: service_healthy + command: [ "carton", "exec", "perl", "script/accesssystem_api_server.pl", "--port", "3000", "--host", "0.0.0.0" ] + +volumes: + postgres_data: diff --git a/test-deploy/seed_data.sql b/test-deploy/seed_data.sql new file mode 100644 index 0000000..b2d1c91 --- /dev/null +++ b/test-deploy/seed_data.sql @@ -0,0 +1,10 @@ +INSERT INTO tiers (id, name, description, price, concessions_allowed, in_use, restrictions) VALUES +(1, 'Other Hackspace', 'Member of another hackspace/makerspace', 500, false, true, '{}'), +(2, 'Standard', 'Standard full membership', 2500, true, true, '{}'), +(3, 'Student', 'Student membership (requires proof)', 1250, false, true, '{}'), +(4, 'Weekend', 'Weekend access only', 1500, true, true, '{"times":[{"from":"6:00:00","to":"7:23:59"}]}'), +(5, 'Men''s Shed', 'Men''s Shed membership', 1000, false, true, '{}'), +(6, 'Donation', 'Donor only membership (no access)', 0, false, true, '{}'); + +INSERT INTO tools (id, name, assigned_ip, requires_induction, team) VALUES +('09637E38-F469-11F0-A94B-FD08D99F0D81', 'The Door', '10.0.0.1', false, 'Everyone'); diff --git a/test-deploy/start.sh b/test-deploy/start.sh new file mode 100755 index 0000000..b63c572 --- /dev/null +++ b/test-deploy/start.sh @@ -0,0 +1,103 @@ +#!/bin/bash +set -e + +# Ensure we're in the right directory +cd "$(dirname "$0")" + +# Check for required tools +if ! command -v docker &> /dev/null; then + echo "❌ Error: 'docker' is not installed or not in PATH." + exit 1 +fi + +if ! docker compose version &> /dev/null; then + echo "❌ Error: 'docker compose' is not available." + echo " Ensure you have a recent version of Docker Desktop installed." + exit 1 +fi + +echo "🚀 Starting Production Container Test Environment..." + +# Create config if it doesn't exist +if [ ! -f config/accesssystem_api_local.conf ]; then + echo "⚙️ Creating default test configuration..." + mkdir -p config + cat > config/accesssystem_api_local.conf < + + dsn dbi:Pg:dbname=accesssystem;host=db + user access + password accesstest + + + +# Dummy reCAPTCHA keys (Google test keys) + + site_key 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI + secret_key 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe + + +# Dummy OneAll settings + + subdomain test + domain test.api.oneall.com + public_key test-public-key + private_key test-private-key + + +# Cookie settings + + name access_system_test + mac_secret docker-test-cookie-secret + + + + namespace accesssystem + + +# Dummy Sendinblue/Brevo settings + + api-key dummy-test-api-key + + +base_url http://localhost:3000/accesssystem/ +EOL +fi + +# Build (using parent context) +echo "📦 Building production image..." +docker build --target production -t accesssystem:latest .. + +# Start containers +echo "🔄 Starting containers..." +docker compose up -d + +# Wait for DB +# Wait for DB to be healthy +echo "⏳ Waiting for Database to be ready..." +RETRIES=30 +until docker compose exec -T db pg_isready -U access -d accesssystem > /dev/null 2>&1; do + ((RETRIES--)) + if [ $RETRIES -le 0 ]; then + echo "❌ Database failed to start in time." + exit 1 + fi + echo "zzz... waiting for database ($RETRIES retries left)" + sleep 2 +done +echo "✅ Database is up!" + +# Deploy Schema +echo "📜 Deploying Schema (v18.0 PostgreSQL)..." +docker compose exec -T db psql -U access -d accesssystem < ../sql/AccessSystem-Schema-18.0-PostgreSQL.sql + +# Seed Data +echo "🌱 Seeding Data..." +docker compose exec -T db psql -U access -d accesssystem < seed_data.sql + +echo "✅ Environment Ready!" +echo "➡️ Login: http://localhost:3000/login" +echo "➡️ Register: http://localhost:3000/register" +echo "" +echo "To stop: docker compose down -v" From 53115b3ceb912060feff8fda0b7c200b88ee22c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C3=96st=C3=B6r?= Date: Sun, 18 Jan 2026 14:33:03 +0000 Subject: [PATCH 06/10] remove outdated comment --- test-deploy/docker-compose.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/test-deploy/docker-compose.yaml b/test-deploy/docker-compose.yaml index b392aa5..e725cff 100644 --- a/test-deploy/docker-compose.yaml +++ b/test-deploy/docker-compose.yaml @@ -1,6 +1,3 @@ -# Temporary docker-compose for testing the production container -# Usage: docker compose -f docker-compose.test.yaml up - services: db: image: postgres:17 From 4e4bdd54e47fdf86f8d28ff63079347f02eb00d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C3=96st=C3=B6r?= Date: Sun, 18 Jan 2026 14:33:22 +0000 Subject: [PATCH 07/10] Update devcontainer build --- .devcontainer/Dockerfile | 47 ++++++++++++++++++++++---- .github/workflows/build-dev-image.yaml | 2 +- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 54ceccc..f260605 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -11,6 +11,15 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ perl \ cpanminus \ build-essential \ + # Database drivers + libdbd-sqlite3-perl \ + libdbd-pg-perl \ + libpq-dev \ + # Libraries for CPAN XS modules + libexpat1-dev \ + libxml2-dev \ + zlib1g-dev \ + # Core Perl modules from apt libalgorithm-diff-perl \ libalgorithm-diff-xs-perl \ libalgorithm-merge-perl \ @@ -37,17 +46,43 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ libtimedate-perl \ liburi-perl \ libscalar-list-utils-perl \ - && cpanm -S Carton \ + libmoose-perl \ + libjson-perl \ + libdata-dump-perl \ + libtry-tiny-perl \ + libdatetime-perl \ + libpath-class-perl \ + libplack-perl \ + libxml-parser-perl \ + libcrypt-des-perl \ + libssl-dev \ + libio-socket-ssl-perl \ + libnet-ssleay-perl \ + ca-certificates \ + && cpanm -n Carton \ # for Perl::LanguageServer && apt-get -y install --no-install-recommends \ libanyevent-perl \ libclass-refresh-perl \ - libdata-dump-perl \ libio-aio-perl \ - libjson-perl \ - libmoose-perl \ libpadwalker-perl \ - libscalar-list-utils-perl \ libcoro-perl \ && cpanm Perl::LanguageServer \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* /root/.cpanm + +# ============================================================================= +# DEPS STAGE - Install CPAN dependencies via Carton +# ============================================================================= +FROM base AS deps + +WORKDIR /workspace + +# Copy dependency files first (for better layer caching) +COPY cpanfile cpanfile.snapshot ./ + +# Create vendor directory structure (for cached install) +COPY vendor/ vendor/ + +# Install dependencies using cached mode +RUN carton install --cached \ + && rm -rf /root/.cpanm diff --git a/.github/workflows/build-dev-image.yaml b/.github/workflows/build-dev-image.yaml index 44b4cac..bf4526b 100644 --- a/.github/workflows/build-dev-image.yaml +++ b/.github/workflows/build-dev-image.yaml @@ -70,7 +70,7 @@ jobs: with: context: . file: .devcontainer/Dockerfile - target: base + target: deps push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} From 537d35c4812e56a95a34c749fda9e2069bb06237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C3=96st=C3=B6r?= Date: Sun, 18 Jan 2026 16:46:28 +0000 Subject: [PATCH 08/10] Add OpenAPI 3 specification for the Access System API --- openapi.yaml | 1227 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1227 insertions(+) create mode 100644 openapi.yaml diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..1c4191b --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,1227 @@ +openapi: 3.0.3 +info: + title: Swindon Makerspace Access System API + description: | + A (semi-)automated access system for registering new members and giving them access to the physical space. + + It provides: + - A registration form for new members to sign up + - The ability to match RFID tokens to members + - An API for controllers (e.g., the Door) to verify if an RFID token is valid + + ## Security Note + + The database stores an expected IP for each Thing controller. These are assigned as fixed IPs + to the controllers by the main network router. The API verifies that the IP of an incoming + request matches the expected IP for the claimed thing controller ID. + version: 1.0.0 + contact: + name: Swindon Makerspace + url: https://www.swindon-makerspace.org + license: + name: Perl Artistic License + url: https://opensource.org/licenses/Artistic-2.0 + +servers: + - url: http://localhost:3000 + description: Local development server + - url: https://members.swindon-makerspace.org + description: Production server + +tags: + - name: Access Control + description: Endpoints for IoT devices and access controllers + - name: Member Registration + description: Endpoints for member registration and profile management + - name: Authentication + description: Login and logout endpoints + - name: Transactions + description: Member transaction/balance endpoints + - name: Admin + description: Administrative endpoints + - name: Telegram + description: Telegram bot integration endpoints + +paths: + /: + get: + summary: Root page + description: Returns a welcome message + operationId: index + responses: + '200': + description: Welcome message + content: + text/html: + schema: + type: string + + /verify: + get: + summary: Verify access token + description: | + Given an access token (RFID) and a thing (device) GUID, check whether the person + owning the token is allowed to access the thing. Checks if payments are up-to-date + and if the member has access rights to the specific thing. + operationId: verify + tags: + - Access Control + parameters: + - name: token + in: query + required: true + description: Access token ID (e.g., RFID card ID) + schema: + type: string + - name: thing + in: query + required: true + description: GUID of the thing/device being accessed + schema: + type: string + format: uuid + responses: + '200': + description: Access verification result + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/VerifySuccessResponse' + - $ref: '#/components/schemas/VerifyDeniedResponse' + examples: + accessGranted: + summary: Access granted + value: + person: + name: "John Doe" + inductor: false + access: 1 + beep: 0 + cache: 1 + colour: 1 + accessDenied: + summary: Access denied + value: + access: 0 + error: "Membership expired" + colour: 35 + + /msglog: + get: + summary: Store a message log + description: | + Allows any Thing to store a message in the message_log table. + Used for device logging and debugging. + operationId: msg_log + tags: + - Access Control + parameters: + - name: thing + in: query + required: true + description: GUID of the thing controller + schema: + type: string + format: uuid + - name: msg + in: query + required: true + description: Text of the message to save + schema: + type: string + responses: + '200': + description: Log result + content: + application/json: + schema: + $ref: '#/components/schemas/LogResponse' + + /tool_access: + get: + summary: Update tool usage state + description: | + Update UsageLog/state for a tool. Called periodically by tool controllers + to track active usage time. + operationId: tool_access + tags: + - Access Control + parameters: + - name: token + in: query + required: true + description: User's access token ID + schema: + type: string + - name: thing + in: query + required: true + description: GUID of the thing controller + schema: + type: string + format: uuid + - name: msg + in: query + required: false + description: Description of what's happening + schema: + type: string + - name: state + in: query + required: true + description: 1 for on, 0 for off + schema: + type: integer + enum: [0, 1] + - name: active_time + in: query + required: true + description: Number of seconds the tool has been active + schema: + type: integer + responses: + '200': + description: Usage logged + content: + application/json: + schema: + $ref: '#/components/schemas/LogResponse' + + /induct: + get: + summary: Record induction + description: | + Records that a trainer (token_t) has inducted a student (token_s) to use a specific thing. + The request must come from the correct IP address assigned to the thing. + operationId: induct + tags: + - Access Control + parameters: + - name: token_t + in: query + required: true + description: Trainer's access token ID + schema: + type: string + - name: token_s + in: query + required: true + description: Student's access token ID + schema: + type: string + - name: thing + in: query + required: true + description: GUID of the thing/tool + schema: + type: string + format: uuid + responses: + '200': + description: Induction result + content: + application/json: + schema: + $ref: '#/components/schemas/InductResponse' + examples: + success: + summary: Induction successful + value: + allowed: 1 + person: + name: "Jane Student" + failure: + summary: Induction failed + value: + allowed: 0 + error: "Trainer not authorized" + + /assign: + get: + summary: Assign access token to member + description: | + Assigns a new access token to a member. Requires admin privileges + and must be called from the TagAssigner device. + operationId: assign + tags: + - Access Control + parameters: + - name: admin_token + in: query + required: true + description: Admin's access token ID + schema: + type: string + - name: person_id + in: query + required: true + description: ID of the person to assign the token to + schema: + type: integer + - name: thing + in: query + required: true + description: GUID of the TagAssigner device + schema: + type: string + format: uuid + - name: token_id + in: query + required: true + description: ID of the new token to assign + schema: + type: string + - name: desc + in: query + required: false + description: Description for the token + schema: + type: string + responses: + '200': + description: Assignment result + content: + application/json: + schema: + type: object + properties: + tokens: + type: integer + description: Number of tokens the member now has + message: + type: string + error: + type: string + + /park: + get: + summary: Auto-park member's vehicles + description: | + Given a member's token and thing ID, automatically registers their + vehicles for parking via the parking API. + operationId: park + tags: + - Access Control + parameters: + - name: token + in: query + required: true + description: Member's access token ID + schema: + type: string + - name: thing + in: query + required: true + description: GUID of the parking controller + schema: + type: string + format: uuid + responses: + '200': + description: Parking result + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /who: + get: + summary: Get person name from token + description: Returns the name of the person who owns the given token + operationId: who + tags: + - Access Control + parameters: + - name: token + in: query + required: true + description: Access token ID + schema: + type: string + responses: + '200': + description: Person name + content: + text/plain: + schema: + type: string + example: "John Doe" + + /register: + get: + summary: Display registration form + description: Displays the member registration form + operationId: register_get + tags: + - Member Registration + responses: + '200': + description: Registration form HTML + content: + text/html: + schema: + type: string + post: + summary: Submit registration + description: Process new member registration + operationId: register_post + tags: + - Member Registration + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PersonRegistration' + responses: + '200': + description: Registration result or form with errors + content: + text/html: + schema: + type: string + '302': + description: Redirect to add child page if has_children is set + + /add_child: + get: + summary: Display add child form + description: Form to add a child member (requires parent_id in session) + operationId: add_child_get + tags: + - Member Registration + responses: + '200': + description: Add child form + content: + text/html: + schema: + type: string + post: + summary: Submit child registration + description: Add a child member under a parent + operationId: add_child_post + tags: + - Member Registration + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ChildRegistration' + responses: + '200': + description: Child added or form with errors + content: + text/html: + schema: + type: string + + /get_dues: + get: + summary: Calculate dues amount + description: | + Returns the amount of dues (in pence) that would be payable using the given + parameters. Used by the registration page to live-update dues. + operationId: get_dues + tags: + - Member Registration + parameters: + - name: dob + in: query + required: false + description: Date of birth (YYYY-MM-DD) + schema: + type: string + format: date + - name: concessionary_rate_override + in: query + required: false + description: Concession type (e.g., student, unemployed) + schema: + type: string + - name: tier + in: query + required: false + description: Membership tier (default 3) + schema: + type: integer + default: 3 + responses: + '200': + description: Dues amount in pounds + content: + text/plain: + schema: + type: number + format: float + example: 25.00 + + /login: + get: + summary: Display login page + description: Shows the login page with social login options (GitHub, Google via OneAll) + operationId: login + tags: + - Authentication + responses: + '200': + description: Login form + content: + text/html: + schema: + type: string + + /logout: + get: + summary: Logout user + description: Removes the authentication cookie and redirects to login + operationId: logout + tags: + - Authentication + responses: + '302': + description: Redirect to login page + + /oneall_login_callback: + post: + summary: OneAll login callback + description: Internal callback endpoint for OneAll social login + operationId: oneall_login_callback + tags: + - Authentication + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + connection_token: + type: string + responses: + '302': + description: Redirect to profile on success, login on failure + + /profile: + get: + summary: View member profile + description: Shows the logged-in member's profile including payment status + operationId: profile + tags: + - Member Registration + security: + - cookieAuth: [] + responses: + '200': + description: Profile page + content: + text/html: + schema: + type: string + '302': + description: Redirect to login if not authenticated + + /editme: + get: + summary: Edit profile form + description: Form to edit the logged-in member's details + operationId: editme_get + tags: + - Member Registration + security: + - cookieAuth: [] + responses: + '200': + description: Edit form + content: + text/html: + schema: + type: string + post: + summary: Update profile + description: Update the logged-in member's details + operationId: editme_post + tags: + - Member Registration + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PersonUpdate' + responses: + '302': + description: Redirect to profile on success + '200': + description: Form with validation errors + + /download: + get: + summary: Download member data + description: Download all data associated with the logged-in member (GDPR export) + operationId: download_data + tags: + - Member Registration + security: + - cookieAuth: [] + responses: + '200': + description: JSON data export + content: + application/json: + schema: + type: array + items: + type: object + + /deleteme: + get: + summary: Delete account confirmation + description: Shows account deletion confirmation page + operationId: delete_me_get + tags: + - Member Registration + security: + - cookieAuth: [] + responses: + '200': + description: Confirmation page + content: + text/html: + schema: + type: string + post: + summary: Delete account + description: Permanently deletes the member's account + operationId: delete_me_post + tags: + - Member Registration + security: + - cookieAuth: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: + - reallyreally + properties: + reallyreally: + type: string + enum: ["yupyup"] + responses: + '302': + description: Redirect to login after deletion + + /delete_token: + get: + summary: Delete access token + description: | + Removes an access token from the logged-in member. + At least one token must remain. + operationId: delete_token + tags: + - Member Registration + security: + - cookieAuth: [] + parameters: + - name: token + in: query + required: true + description: Token ID to delete + schema: + type: string + responses: + '302': + description: Redirect to profile + + /delete_vehicle: + get: + summary: Delete vehicle + description: Removes a vehicle from the logged-in member's account + operationId: delete_vehicle + tags: + - Member Registration + security: + - cookieAuth: [] + parameters: + - name: vehicle + in: query + required: true + description: Vehicle plate registration to delete + schema: + type: string + responses: + '302': + description: Redirect to profile + + /transaction: + post: + summary: Record a transaction + description: | + Records a debit transaction for a member. Can be called either with + a user hash (from app) or with a token and thing (from IoT device). + operationId: record_transaction + tags: + - Transactions + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + oneOf: + - type: object + required: [hash, amount, reason] + properties: + hash: + type: string + description: User GUID hash + amount: + type: integer + description: Amount in pence + reason: + type: string + description: Transaction description + - type: object + required: [token, thing, amount, reason] + properties: + token: + type: string + description: Access token ID + thing: + type: string + format: uuid + description: Thing GUID + amount: + type: integer + description: Amount in pence + reason: + type: string + description: Transaction description + responses: + '200': + description: Transaction result + content: + application/json: + schema: + type: object + properties: + success: + type: integer + enum: [0, 1] + error: + type: string + balance: + type: integer + description: Current balance in pence + + /get_transactions/{count}/{user_hash}: + get: + summary: Get recent transactions + description: Returns the N most recent transactions for a member + operationId: get_transactions + tags: + - Transactions + parameters: + - name: count + in: path + required: true + description: Number of transactions to return + schema: + type: integer + - name: user_hash + in: path + required: true + description: User GUID hash + schema: + type: string + responses: + '200': + description: Transaction list + content: + application/json: + schema: + type: object + properties: + transactions: + type: array + items: + type: object + properties: + added_on: + type: string + format: date-time + reason: + type: string + amount: + type: integer + description: Amount in pence + balance: + type: integer + description: Current balance in pence + + /user_guid_request: + get: + summary: Request user GUID email + description: | + Sends an email to the member containing their GUID for use in the phone app. + operationId: user_guid_request + tags: + - Member Registration + parameters: + - name: userid + in: query + required: true + description: Member ID (with or without SM prefix) + schema: + type: string + example: "SM0001" + responses: + '200': + description: Request result + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /confirm_telegram: + get: + summary: Initiate Telegram confirmation + description: | + Called from the Telegram bot identify command. Sends an email to the + member to confirm they wish to link their Telegram account. + operationId: confirm_telegram + tags: + - Telegram + parameters: + - name: email + in: query + required: true + description: Member's email address + schema: + type: string + format: email + - name: chatid + in: query + required: true + description: Telegram chat ID + schema: + type: string + - name: username + in: query + required: false + description: Telegram username + schema: + type: string + responses: + '200': + description: Confirmation result + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /confirm_email: + get: + summary: Confirm email link + description: | + Processes the email confirmation link for Telegram account linking. + Stores the Telegram chat ID and username in the member's record. + operationId: confirm_email + tags: + - Telegram + parameters: + - name: token + in: query + required: true + description: Confirmation token from email + schema: + type: string + format: uuid + responses: + '302': + description: Redirect to post_confirm page + + /post_confirm: + get: + summary: Post-confirmation page + description: Shows a confirmation success page after email confirmation + operationId: post_confirm + tags: + - Telegram + parameters: + - name: type + in: query + required: false + description: Type of confirmation (telegram, induction) + schema: + type: string + responses: + '200': + description: Confirmation page + content: + text/html: + schema: + type: string + + /send_induction_acceptance: + get: + summary: Send induction acceptance email + description: Sends an email to a member to accept their tool induction + operationId: send_induction_acceptance + tags: + - Admin + parameters: + - name: tool + in: query + required: true + description: Tool GUID + schema: + type: string + format: uuid + - name: person + in: query + required: true + description: Person ID + schema: + type: integer + responses: + '200': + description: Send result + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /confirm_induction: + get: + summary: Confirm induction acceptance + description: Processes the induction acceptance from the email link + operationId: confirm_induction + tags: + - Admin + parameters: + - name: token + in: query + required: true + description: Confirmation token from email + schema: + type: string + format: uuid + responses: + '302': + description: Redirect to post_confirm page + + /resendemail/{id}: + get: + summary: Resend membership email + description: Resends the membership confirmation/payment details email to a member + operationId: resend_email + tags: + - Admin + parameters: + - name: id + in: path + required: true + description: Member ID + schema: + type: integer + responses: + '200': + description: Send result + content: + application/json: + schema: + type: object + properties: + message: + type: string + + /nudge_member/{id}: + get: + summary: Send payment reminder + description: | + Sends an email to an expired member asking if they stopped paying on purpose. + Only works for expired members who haven't been marked as "ended". + operationId: nudge_member + tags: + - Admin + parameters: + - name: id + in: path + required: true + description: Member ID + schema: + type: integer + responses: + '200': + description: Reminder result + content: + application/json: + schema: + type: object + properties: + message: + type: string + + /box_reminder/{id}: + get: + summary: Send member box reminder + description: Sends a reminder email about member box storage to an expired member + operationId: box_reminder + tags: + - Admin + parameters: + - name: id + in: path + required: true + description: Member ID + schema: + type: integer + responses: + '200': + description: Reminder result + content: + application/json: + schema: + type: object + properties: + message: + type: string + + /membership_status_update: + get: + summary: Generate membership status report + description: | + Collects data about current membership numbers (paying, expired, ex, + children, concessions, etc.) and emails it to the directors. + operationId: membership_status_update + tags: + - Admin + responses: + '200': + description: Membership statistics + content: + application/json: + schema: + type: object + properties: + msg_text: + type: string + recently: + type: string + additionalProperties: true + + /vehicles: + get: + summary: List valid member vehicles + description: | + Returns a plain-text list of vehicle registration plates for + currently paid-up members, sorted alphabetically. + operationId: vehicles + tags: + - Admin + security: + - cookieAuth: [] + responses: + '200': + description: Vehicle list + content: + text/plain: + schema: + type: string + example: "AB12CDE\r\nFG34HIJ\r\n" + + /membership_register: + get: + summary: View membership register + description: Displays the membership register as of a given date + operationId: membership_register + tags: + - Admin + security: + - cookieAuth: [] + parameters: + - name: at_date + in: query + required: false + description: Date to view register for (YYYY-MM-DD), defaults to today + schema: + type: string + format: date + responses: + '200': + description: Membership register page + content: + text/html: + schema: + type: string + +components: + securitySchemes: + cookieAuth: + type: apiKey + in: cookie + name: accesssystem_cookie + description: Session cookie set after OneAll login + + schemas: + VerifySuccessResponse: + type: object + required: + - access + properties: + person: + type: object + properties: + name: + type: string + inductor: + type: boolean + description: Whether the person is a trainer for this thing + access: + type: integer + enum: [1] + beep: + type: integer + enum: [0, 1] + cache: + type: integer + enum: [0, 1] + description: Whether access can be cached (0 if time-restricted) + colour: + type: integer + description: Door LED colour code + + VerifyDeniedResponse: + type: object + required: + - access + properties: + access: + type: integer + enum: [0] + error: + type: string + description: Reason for denial + colour: + type: integer + description: Door LED colour code + + LogResponse: + type: object + properties: + logged: + type: integer + enum: [0, 1] + error: + type: string + + InductResponse: + type: object + properties: + allowed: + type: integer + enum: [0, 1] + person: + type: object + properties: + name: + type: string + error: + type: string + + SuccessResponse: + type: object + properties: + success: + type: integer + enum: [0, 1] + message: + type: string + error: + type: string + + PersonRegistration: + type: object + required: + - name + - email + - address + - membership_guide + properties: + name: + type: string + description: Full name + email: + type: string + format: email + address: + type: string + description: Full postal address + dob: + type: string + format: date + description: Date of birth + tier: + type: integer + description: Membership tier + default: 3 + concessionary_rate_override: + type: string + description: Concession reason if applicable + payment_override: + type: integer + description: Custom payment amount in pence + door_colour: + type: string + description: LED colour preference + default: green + membership_guide: + type: boolean + description: Confirmed reading the membership guide + has_children: + type: boolean + description: Whether to add child members + + ChildRegistration: + type: object + required: + - name + - dob + properties: + name: + type: string + email: + type: string + format: email + dob: + type: string + format: date + more_children: + type: boolean + description: Add another child after this one + + PersonUpdate: + type: object + properties: + name: + type: string + email: + type: string + format: email + address: + type: string + tier: + type: integer + concessionary_rate_override: + type: string + payment_override: + type: integer + door_colour: + type: string From 847f1587b3a8c6037efc1461d8a8d4ef44765ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C3=96st=C3=B6r?= Date: Sun, 18 Jan 2026 19:11:17 +0000 Subject: [PATCH 09/10] Implement OpenAPI specification generation by parsing controller POD documentation --- README.md | 49 + lib/AccessSystem/API/Controller/Root.pm | 589 ++++++++++- lib/AccessSystem/API/Controller/v2.pm | 16 + lib/AccessSystem/API/OpenAPI.pm | 693 +++++++++++++ openapi.yaml | 1227 ----------------------- script/generate_openapi.pl | 157 +++ 6 files changed, 1492 insertions(+), 1239 deletions(-) create mode 100644 lib/AccessSystem/API/OpenAPI.pm delete mode 100644 openapi.yaml create mode 100644 script/generate_openapi.pl diff --git a/README.md b/README.md index 45c7b9e..3555c9b 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,55 @@ The GET `confirm_email` endpoint confirms+stores the telegram chatid/username in The GET `induction_acceptance` endpoint, given a `tool` id and a `person` id, sets "pending acceptance" to false, for that combination of tool and person, in the `allowed` table. +API Documentation / OpenAPI +--------------------------- + +The system now supports automatic OpenAPI 3.1 specification generation. + +### OpenAPI Endpoints + +* **Spec**: `GET /openapi` - Returns the JSON specification. +* **UI**: `GET /openapi/ui` - Interactive Swagger UI documentation. + +### Adding New Routes + +To document a new endpoint, add POD documentation above your controller subroutine. The system automatically parses `Tags`, `Methods`, and parameter lists. + +**Format:** + +```perl +=head2 endpoint_name + +Description of what this endpoint does. + +Tags: Category Name +Methods: GET, POST + +=over + +=item param_name (required, integer) - Description of parameter + +=item optional_param - Description + +=back + +=cut + +sub endpoint_name : Path(...) { ... } +``` + +**Supported Modifiers:** `required`, `uuid`, `email`, `integer`, `boolean`, `number`. + +### Automatic Detection + +If `Methods:` is omitted, the system analyzes the source code to detect if `POST` is used (e.g. usage of `body_params` or database writes). Otherwise it defaults to `GET`. + +### CLI Tool + +You can inspect the generated spec from the command line: + + carton exec perl script/generate_openapi.pl > openapi.yaml + Security & DPA -------------- diff --git a/lib/AccessSystem/API/Controller/Root.pm b/lib/AccessSystem/API/Controller/Root.pm index 3814df8..445172d 100644 --- a/lib/AccessSystem/API/Controller/Root.pm +++ b/lib/AccessSystem/API/Controller/Root.pm @@ -10,6 +10,7 @@ use LWP::UserAgent; use MIME::Base64; use JSON; use Data::GUID; +use AccessSystem::API::OpenAPI; BEGIN { extends 'Catalyst::Controller' } @@ -70,6 +71,22 @@ sub default :Path { # insert into tools (id, name, assigned_ip) values ('D1CAE50C-0C2C-11E7-84F0-84242E34E104', 'oneall_login_callback', '192.168.1.70'); +=head2 oneall_login_callback + +Callback for OneAll social login. + +Tags: Authentication + +Methods: POST + +=over + +=item connection_token (required) - OneAll connection token + +=back + +=cut + sub oneall_login_callback : Path('/oneall_login_callback') { my ($self, $c) = @_; @@ -124,12 +141,32 @@ sub oneall_login_callback : Path('/oneall_login_callback') { } } +=head2 login + +Login page. + +Tags: Authentication + +Methods: GET + +=cut + sub login : Path('/login') { my ($self, $c) = @_; $c->stash(template => 'login.tt'); } +=head2 logout + +Logout action. + +Tags: Authentication + +Methods: GET + +=cut + sub logout : Path('/logout') { my ($self, $c) = @_; @@ -172,6 +209,16 @@ sub logged_in: Chained('base') :PathPart(''): CaptureArgs(0) { $c->stash->{person} = $person; } +=head2 profile + +Member profile page. + +Tags: Member Registration + +Methods: GET + +=cut + sub profile : Chained('logged_in') :PathPart('profile'): Args(0) { my ($self, $c) = @_; @@ -187,6 +234,30 @@ sub profile : Chained('logged_in') :PathPart('profile'): Args(0) { $c->stash->{template} = 'profile.tt'; } +=head2 editme + +Member profile edit form. + +Tags: Member Registration + +Methods: GET, POST + +=over + +=item name - Full name + +=item email - Email address + +=item address - Physical address + +=item postcode - Postcode + +=item opt_in - Marketing opt-in + +=back + +=cut + sub editme : Chained('logged_in') :PathPart('editme'): Args(0) { my ($self, $c) = @_; @@ -206,6 +277,16 @@ sub editme : Chained('logged_in') :PathPart('editme'): Args(0) { } } +=head2 download_data + +Download member data as JSON. + +Tags: Member Registration + +Methods: GET + +=cut + sub download_data: Chained('logged_in') :PathPart('download'): Args(0) { my ($self, $c) = @_; @@ -223,6 +304,22 @@ sub download_data: Chained('logged_in') :PathPart('download'): Args(0) { } +=head2 delete_me + +Delete member account. + +Tags: Member Registration + +Methods: GET, POST + +=over + +=item reallyreally (required) - Confirmation string "yupyup" to delete account + +=back + +=cut + sub delete_me :Chained('logged_in'): PathPart('deleteme'): Args(0) { my ($self, $c) = @_; @@ -235,6 +332,23 @@ sub delete_me :Chained('logged_in'): PathPart('deleteme'): Args(0) { } } +=head2 delete_token + +Delete an access token. +These are GET reqs (cos lazy, and its a link) + +Tags: Member Registration + +Methods: GET + +=over + +=item token (required) - Token ID to delete + +=back + +=cut + sub delete_token :Chained('logged_in'): PathPart('delete_token'): Args(0) { my ($self, $c) = @_; my $token = $c->req->params->{token}; @@ -250,6 +364,23 @@ sub delete_token :Chained('logged_in'): PathPart('delete_token'): Args(0) { return $c->response->redirect($c->uri_for('profile')); } +=head2 delete_vehicle + +Delete a vehicle. +These are GET reqs (cos lazy, and its a link) + +Tags: Member Registration + +Methods: GET + +=over + +=item vehicle (required) - Vehicle registration to delete + +=back + +=cut + sub delete_vehicle :Chained('logged_in'): PathPart('delete_vehicle'): Args(0) { my ($self, $c) = @_; my $vehicle = $c->req->params->{vehicle}; @@ -262,6 +393,22 @@ sub delete_vehicle :Chained('logged_in'): PathPart('delete_vehicle'): Args(0) { return $c->response->redirect($c->uri_for('profile')); } +=head2 who + +Identify a member by their token. + +Tags: Access Control + +Methods: GET + +=over + +=item token (required) - Access token ID + +=back + +=cut + sub who : Chained('base') : PathPart('who') : Args(0) { my ($self, $c) = @_; @@ -278,6 +425,24 @@ sub who : Chained('base') : PathPart('who') : Args(0) { ## eg "the Main Door", check whether they both exist as ids, and ## whether a person owning said token is allowed to access said thing. +=head2 verify + +Verify access for a token at a thing/controller. + +Tags: Access Control + +Methods: GET + +=over + +=item token (required) - Access token ID + +=item thing (required, uuid) - Controller GUID + +=back + +=cut + sub verify: Chained('base') :PathPart('verify') :Args(0) { my ($self, $c) = @_; @@ -343,15 +508,17 @@ sub verify: Chained('base') :PathPart('verify') :Args(0) { $c->res->content_length(length($c->res->body)); } -=head2 msglog +=head2 msg_log -Query Params: +Logs a message from a thing controller. + +Tags: Access Control =over -=item thing = GUID of the thing controller doing the checking +=item thing (required, uuid) - GUID of the thing controller doing the checking -=item msg = text of the message to save +=item msg (required) - text of the message to save =back @@ -382,21 +549,21 @@ sub msg_log: Chained('base'): PathPart('msglog'): Args() { =head2 tool_access -Update UsageLog/state for a tool +Update UsageLog/state for a tool. -Query Params: +Tags: Access Control =over -=item token = user's access token id +=item token (required) - User's access token ID -=item thing = GUID of the thing controller doing the checking +=item thing (required, uuid) - GUID of the thing controller -=item msg = description of whats happening +=item msg - Description of activity -=item state = 1 (on), 0 (off) +=item state - 1 (on) or 0 (off) -=item active_time = number of seconds tool has been active +=item active_time - Number of seconds tool has been active =back @@ -447,6 +614,23 @@ sub tool_access: Chained('base'): PathPart('tool_access'): Args() { } ## Thing X (from correct IP Y) says person T inducts person S to use it: +=head2 induct + +Record induction between two tokens/members. + +Tags: Access Control + +=over + +=item token_t (required) - Trainer token + +=item token_s (required) - Student token + +=item thing (required, uuid) - Tool GUID + +=back + +=cut sub induct: Chained('base'): PathPart('induct'): Args() { my ($self, $c) = @_; @@ -505,6 +689,26 @@ sub induct: Chained('base'): PathPart('induct'): Args() { $c->forward('View::JSON'); } +=head2 assign + +Assign a token to a member (Admin). + +Tags: Access Control + +=over + +=item admin_token (required) - Admin's token + +=item person_id (required) - Member ID to assign to + +=item token_id (required) - New Token ID + +=item thing (required) - Tag Assigner GUID + +=back + +=cut + sub assign: Chained('base'): PathPart('assign'): Args(0) { my ($self, $c) = @_; @@ -568,6 +772,22 @@ sub assign: Chained('base'): PathPart('assign'): Args(0) { $c->forward('View::JSON'); } +=head2 park + +Parks a vehicle for a member identified by their token. + +Tags: Access Control + +=over + +=item token (required) - Access token ID of the member + +=item thing (required, uuid) - GUID of the parking controller + +=back + +=cut + sub park: Chained('base'): PathPart('park'): Args(0) { my ($self, $c) = @_; @@ -645,6 +865,30 @@ sub park: Chained('base'): PathPart('park'): Args(0) { $c->res->content_length(length($c->res->body)); } +=head2 record_transaction + +Record a financial transaction. + +Tags: Transactions + +Methods: POST + +=over + +=item amount (required) - Amount in pence (negative for debit) + +=item reason (required) - Reason for transaction + +=item token - Member token ID (if using token) + +=item thing - Controller GUID (if using token) + +=item hash - Member Hash (if using hash) + +=back + +=cut + sub record_transaction: Chained('base'): PathPart('transaction'): Args(0) { my ($self, $c) = @_; @@ -725,7 +969,19 @@ sub record_transaction: Chained('base'): PathPart('transaction'): Args(0) { =head2 get_transactions -Get most N recent transactions +Get most N recent transactions. + +Tags: Transactions + +Methods: GET + +=over + +=item count (required) - Number of transactions to retrieve + +=item userhash (required) - User hash + +=back =cut @@ -758,9 +1014,21 @@ sub get_transactions: Chained('base'): PathPart('get_transactions'): Args(2) { =head2 user_guid_request +Request user GUID email for app login. + Given a user id, send the member with that id an email, containing their guid. This is for putting into the phone app. +Tags: Access Control + +Methods: GET + +=over + +=item userid (required) - Member Reference (e.g. SM00123) + +=back + =cut sub user_guid_request: Chained('base'): PathPart('user_guid_request'): Args(0) { @@ -809,6 +1077,26 @@ sub user_guid_request: Chained('base'): PathPart('user_guid_request'): Args(0) { $c->forward($c->view('JSON')); } +=head2 confirm_telegram + +Initiate Telegram confirmation. + +Tags: Telegram + +Methods: GET + +=over + +=item email (required) - Member email address + +=item chatid (required) - Telegram chat ID + +=item username - Telegram username + +=back + +=cut + sub confirm_telegram: Chained('base'): PathPart('confirm_telegram'): Args(0) { my ($self, $c) = @_; @@ -856,6 +1144,22 @@ sub confirm_telegram: Chained('base'): PathPart('confirm_telegram'): Args(0) { $c->forward($c->view('JSON')); } +=head2 confirm_email + +Confirm email/telegram token. + +Tags: Telegram + +Methods: GET + +=over + +=item token (required) - Confirmation token + +=back + +=cut + sub confirm_email: Chained('base'): PathPart('confirm_email'): Args(0) { my ($self, $c) = @_; @@ -870,6 +1174,22 @@ sub confirm_email: Chained('base'): PathPart('confirm_email'): Args(0) { return $c->res->redirect($c->uri_for('post_confirm', { type => 'telegram' })); } +=head2 post_confirm + +Confirmation success page. + +Tags: Telegram + +Methods: GET + +=over + +=item type (required) - Confirmation type (e.g. 'telegram') + +=back + +=cut + sub post_confirm: Chained('base'): PathPart('post_confirm'): Arg(0) { my ($self, $c) = @_; @@ -878,6 +1198,24 @@ sub post_confirm: Chained('base'): PathPart('post_confirm'): Arg(0) { $c->stash->{template} = 'post_confirm.tt'; } +=head2 send_induction_acceptance + +Send induction acceptance email. + +Tags: Admin + +Methods: GET + +=over + +=item tool (required) - Tool ID + +=item person (required) - Person ID + +=back + +=cut + sub send_induction_acceptance: Chained('base'): PathPart('send_induction_acceptance'): Args(0) { my ($self, $c) = @_; @@ -913,6 +1251,22 @@ sub send_induction_acceptance: Chained('base'): PathPart('send_induction_accepta my $token = $c->req->params->{token}; } +=head2 confirm_induction + +Confirm induction acceptance. + +Tags: Admin + +Methods: GET + +=over + +=item token (required) - Induction confirmation token + +=back + +=cut + sub confirm_induction: Chained('base'): PathPart('confirm_induction'): Args(0) { my ($self, $c) = @_; @@ -928,6 +1282,8 @@ sub confirm_induction: Chained('base'): PathPart('confirm_induction'): Args(0) { =head2 get_dues +Calculate dues based on input parameters. + Returns amount of dues, in pence, which would be payable using current input values of: date of birth (dob), concession rate (concessionary_rate_override), and tier (tier) chosen. @@ -935,6 +1291,21 @@ input values of: date of birth (dob), concession rate Used by the L page to live-update dues values when prospective members change rates/concession choices. + +Tags: Member Registration + +Methods: GET + +=over + +=item dob - Date of Birth + +=item concessionary_rate_override - Concession rate + +=item tier - Tier ID + +=back + =cut sub get_dues: Chained('base'): PathPart('get_dues'): Args(0) { @@ -956,6 +1327,30 @@ sub get_dues: Chained('base'): PathPart('get_dues'): Args(0) { $c->response->body($dummy_dues / 100); } +=head2 register + +New member registration form. + +Tags: Member Registration + +Methods: GET, POST + +=over + +=item name (required) - Full Name + +=item email (required) - Email Address + +=item address (required) - Physical Address + +=item postcode (required) - Postcode + +=item dob - Date of Birth + +=back + +=cut + sub register: Chained('base'): PathPart('register'): Args(0) { my ($self, $c) = @_; @@ -990,6 +1385,24 @@ sub register: Chained('base'): PathPart('register'): Args(0) { } } +=head2 add_child + +Add child form for registration. + +Tags: Member Registration + +Methods: GET, POST + +=over + +=item name (required) - Child's Name + +=item dob (required) - Child's DOB + +=back + +=cut + sub add_child: Chained('base') :PathPart('add_child') :Args(0) { my ($self, $c) = @_; @@ -1065,6 +1478,22 @@ sub finish_new_member: Private { } +=head2 resend_email + +Resend membership email. + +Tags: Admin + +Methods: GET + +=over + +=item id (required) - Member ID + +=back + +=cut + sub resend_email: Chained('base'): PathPart('resendemail'): Args(1) { my ($self, $c, $id) = @_; my $member = $c->model('AccessDB::Person')->find({ id => $id }); @@ -1094,6 +1523,22 @@ sub send_membership_email: Private { $self->emailer->send($comms); } +=head2 nudge_member + +Send reminder email to member. + +Tags: Admin + +Methods: GET + +=over + +=item id (required) - Member ID + +=back + +=cut + sub nudge_member: Chained('base'): PathPart('nudge_member'): Args(1) { my ($self, $c, $id) = @_; my $member = $c->model('AccessDB::Person')->find({ id => $id }); @@ -1146,6 +1591,22 @@ sub send_reminder_email: Private { $self->emailer->send($comms); } +=head2 box_reminder + +Send box reminder email to member. + +Tags: Admin + +Methods: GET + +=over + +=item id (required) - Member ID + +=back + +=cut + sub box_reminder: Chained('base'): PathPart('box_reminder'): Args(1) { my ($self, $c, $id) = @_; my $member = $c->model('AccessDB::Person')->find({ id => $id }); @@ -1188,6 +1649,10 @@ sub send_box_reminder_email: Private { Collect and send out details about current membership to info@swindon-makerspace.org. No display! +Tags: Admin + +Methods: GET + =cut sub membership_status_update : Chained('base') :PathPart('membership_status_update') { @@ -1252,6 +1717,10 @@ sub verify_token { Plain-text list of vehicles, of currently paid-up members, sorted alphabetically. +Tags: Admin + +Methods: GET + =cut sub vehicles : Chained('logged_in') :PathPart('vehicles') { @@ -1276,6 +1745,22 @@ sub vehicles : Chained('logged_in') :PathPart('vehicles') { $c->res->body($output_str); } +=head2 membership_register + +View membership register. + +Tags: Admin + +Methods: GET + +=over + +=item at_date - Date to view register for (YYYY-MM-DD) + +=back + +=cut + sub membership_register : Chained('logged_in') :PathPart('membership_register') { my ($self, $c) = @_; @@ -1300,6 +1785,86 @@ sub end : ActionClass('RenderView') { $c->stash( current_view => 'TT'); } + +=head2 openapi_spec + +Serves the OpenAPI 3.1 specification as JSON. + +Tags: Documentation + +Methods: GET + +=cut + +sub openapi_spec :Path('/openapi') :Args(0) { + my ($self, $c) = @_; + + my $spec = AccessSystem::API::OpenAPI->generate_spec($c); + + $c->response->content_type('application/json'); + $c->response->body(encode_json($spec)); +} + +=head2 openapi_ui + +Serves Swagger UI for interactive API documentation. + +Tags: Documentation + +Methods: GET + +=cut + +sub openapi_ui :Path('/openapi/ui') :Args(0) { + my ($self, $c) = @_; + + my $spec_url = $c->uri_for('/openapi'); + + my $html = <<"HTML"; + + + + + + Swindon Makerspace API Documentation + + + + +
+ + + + + +HTML + + $c->response->content_type('text/html; charset=utf-8'); + $c->response->body($html); +} + + =head1 AUTHOR Catalyst developer diff --git a/lib/AccessSystem/API/Controller/v2.pm b/lib/AccessSystem/API/Controller/v2.pm index b83cfe2..4dbd2da 100644 --- a/lib/AccessSystem/API/Controller/v2.pm +++ b/lib/AccessSystem/API/Controller/v2.pm @@ -25,6 +25,22 @@ This one should have some sorta auth protection on it, and should log all uses. sub authenticated :Chained('/') :PathPart('v2') :CaptureArgs(0) { } +=head2 remove_ex_member_inductions + +Remove inductions for ex-members. + +Tags: Admin + +Methods: GET + +=over + +=item num_months (required) - Number of months since leaving + +=back + +=cut + sub remove_ex_member_inductions: Chained('authenticated'): PathPart('remove_ex_member_inductions'): Args(1) { my ($self, $c, $num_months) = @_; diff --git a/lib/AccessSystem/API/OpenAPI.pm b/lib/AccessSystem/API/OpenAPI.pm new file mode 100644 index 0000000..5d3b4ea --- /dev/null +++ b/lib/AccessSystem/API/OpenAPI.pm @@ -0,0 +1,693 @@ +package AccessSystem::API::OpenAPI; + +use strict; +use warnings; +use Data::Dumper; +use JSON; +use File::Find; +use File::Spec; + +our $VERSION = '1.0.0'; + +# Tag descriptions for OpenAPI spec +my %TAG_DESCRIPTIONS = ( + 'Access Control' => 'Endpoints for IoT devices and access controllers', + 'Member Registration' => 'Endpoints for member registration and profile management', + 'Authentication' => 'Login and logout endpoints', + 'Transactions' => 'Member transaction/balance endpoints', + 'Admin' => 'Administrative endpoints', + 'Telegram' => 'Telegram bot integration endpoints', + 'Documentation' => 'API documentation endpoints', +); + +# Caches for parsed POD metadata +my %_pod_cache; # Stores { params => [...], tag => '...', methods => [...] } + +=head1 NAME + +AccessSystem::API::OpenAPI - Generate OpenAPI spec from Catalyst app + +=head1 SYNOPSIS + + use AccessSystem::API::OpenAPI; + + # From a controller + my $spec = AccessSystem::API::OpenAPI->generate_spec($c); + + # From a script + my $spec = AccessSystem::API::OpenAPI->generate_spec_from_app('AccessSystem::API'); + +=head1 DESCRIPTION + +This module generates an OpenAPI 3.1 specification by introspecting +Catalyst dispatchers and parsing POD documentation from controller files. + +=cut + +sub new { + my ($class, %args) = @_; + return bless \%args, $class; +} + +=head2 generate_spec($c) + +Generate OpenAPI spec from a Catalyst context object. + +=cut + +sub generate_spec { + my ($class, $c) = @_; + + my $base_url = $c->request->base; + $base_url =~ s{/$}{}; + + return $class->_build_spec( + dispatcher => $c->dispatcher, + base_url => $base_url, + app_class => ref($c) || $c, + ); +} + +=head2 generate_spec_from_app($app_class) + +Generate OpenAPI spec by loading a Catalyst app class. + +=cut + +sub generate_spec_from_app { + my ($class, $app_class) = @_; + + eval "require $app_class" or die "Cannot load $app_class: $@"; + $app_class->setup_finalize() if $app_class->can('setup_finalize'); + + return $class->_build_spec( + dispatcher => $app_class->dispatcher, + base_url => 'http://localhost:3000', + app_class => $app_class, + ); +} + +sub _build_spec { + my ($class, %args) = @_; + + my $dispatcher = $args{dispatcher}; + my $base_url = $args{base_url}; + my $app_class = $args{app_class}; + + my $spec = { + openapi => '3.1.0', + info => { + title => 'Swindon Makerspace Access System API', + description => 'A (semi-)automated access system for registering new members and giving them access to the physical space. +It provides: +- A registration form for new members to sign up +- The ability to match RFID tokens to members +- An API for controllers (e.g., the Door) to verify if an RFID token is valid + +## Security Note + +The database stores an expected IP for each Thing controller. These are assigned as fixed IPs +to the controllers by the main network router. The API verifies that the IP of an incoming +request matches the expected IP for the claimed thing controller ID.', + version => $VERSION, + contact => { + name => 'Swindon Makerspace', + url => 'https://www.swindon-makerspace.org', + }, + }, + servers => [ + { url => $base_url, description => 'Current server' }, + { url => 'https://inside.swindon-makerspace.org', description => 'Production server' }, + ], + paths => {}, + components => { + schemas => {}, + securitySchemes => { + cookieAuth => { + type => 'apiKey', + in => 'cookie', + name => 'accesssystem_cookie', + }, + }, + }, + tags => [], + }; + + my %tags; + + # Parse POD from all controller files + $class->_parse_all_controller_pods($app_class); + + # Process dispatch types + for my $dispatch_type (@{$dispatcher->dispatch_types}) { + my $type = ref($dispatch_type); + + if ($type eq 'Catalyst::DispatchType::Path') { + my $paths = $dispatch_type->_paths || {}; + for my $path (keys %$paths) { + for my $action (@{$paths->{$path}}) { + $class->_add_path_from_action($spec, $path, $action, \%tags); + } + } + } + elsif ($type eq 'Catalyst::DispatchType::Chained') { + my $endpoints = $dispatch_type->_endpoints || []; + for my $action (@$endpoints) { + my $path = $class->_build_chained_path($dispatch_type, $action); + $class->_add_path_from_action($spec, $path, $action, \%tags, { no_args => 1 }) if $path; + } + } + } + + # Build tags array with descriptions + for my $tag (sort keys %tags) { + push @{$spec->{tags}}, { + name => $tag, + ($TAG_DESCRIPTIONS{$tag} ? (description => $TAG_DESCRIPTIONS{$tag}) : ()), + }; + } + + return $spec; +} + +sub _add_path_from_action { + my ($class, $spec, $path, $action, $tags, $opts) = @_; + $opts ||= {}; + + $path = '/' . $path unless $path =~ m{^/}; + $path =~ s{/+}{/}g; + + # Skip internal actions + return if $action->name =~ /^(auto|begin|end|default|index)$/; + return if $action->attributes->{Private}; + + my $method_name = $action->name; + + # Get metadata from POD documentation + my $tag = $class->_get_tag($method_name); + + # Skip undocumented endpoints + return if $tag eq 'Uncategorised'; + + # Handle Args at end (only if not already handled by chained path builder) + my $args = $action->attributes->{Args}; + if (!$opts->{no_args} && $args && @$args && $args->[0] ne '' && $args->[0] =~ /^\d+$/) { + for (1..$args->[0]) { + $path .= "/{arg$_}"; + } + } + + # Get tag from POD + $tags->{$tag} = 1; + + # Rename {argN} using signature parameters + my $sig_params = $class->_get_signature_params($method_name); + if ($sig_params && @$sig_params) { + $path =~ s/\{arg(\d+)\}/ $sig_params->[$1-1] ? "{" . $sig_params->[$1-1] . "}" : "{arg$1}" /ge; + } + + # Determine HTTP methods from POD or source analysis + my @methods = $class->_get_http_methods($method_name); + + for my $http_method (@methods) { + my $operation = { + operationId => $method_name . ($http_method eq 'post' ? '_post' : ''), + summary => $class->_humanize_name($method_name), + tags => [$tag], + responses => { + '200' => { description => 'Successful response' }, + }, + }; + + # Add description from POD + my $desc = $class->_get_description($method_name); + $operation->{description} = $desc if $desc; + + # Adjust operation details based on method + if ($http_method eq 'post') { + $operation->{requestBody} = { + required => JSON::true, + content => { + 'application/x-www-form-urlencoded' => { + schema => { type => 'object' } + } + } + }; + } + + # Add parameters from POD + my @path_params = ($path =~ /\{(\w+)\}/g); + my %path_params_seen; + if (@path_params) { + $operation->{parameters} = []; + + # Fetch POD params to augment path params + my $pod_params = $class->_get_query_params($method_name) || []; + my %pod_params_map = map { $_->{name} => $_ } @$pod_params; + + for my $param (@path_params) { + $path_params_seen{$param} = 1; + my $param_def = { + name => $param, + in => 'path', + required => JSON::true, + schema => { type => 'string' }, + }; + + # Merge details from POD if available + if ($pod_params_map{$param}) { + $param_def->{description} = $pod_params_map{$param}->{description}; + $param_def->{schema} = $pod_params_map{$param}->{schema}; + } + + push @{$operation->{parameters}}, $param_def; + } + } + + # Add query parameters from POD (excluding those used in path) + my $query_params = $class->_get_query_params($method_name); + if ($query_params && @$query_params) { + $operation->{parameters} ||= []; + for my $p (@$query_params) { + next if $path_params_seen{$p->{name}}; + push @{$operation->{parameters}}, $p; + } + } + + $spec->{paths}{$path} ||= {}; + $spec->{paths}{$path}{$http_method} = $operation; + } +} + +sub _build_chained_path { + my ($class, $dispatch_type, $action) = @_; + + my @parts; + my $current = $action; + my %seen; + + while ($current && !$seen{$current}++) { + my $path_part = $current->attributes->{PathPart}; + if ($path_part && @$path_part && $path_part->[0] ne '') { + unshift @parts, $path_part->[0]; + } + + my $capture = $current->attributes->{CaptureArgs}; + if ($capture && @$capture && $capture->[0] > 0) { + for (1..$capture->[0]) { + push @parts, "{capture_$_}"; + } + } + + my $parent = $current->attributes->{Chained}; + last unless $parent && @$parent; + + my $parent_action = $dispatch_type->_actions->{$parent->[0]}; + last unless $parent_action; + $current = $parent_action; + } + + my $args = $action->attributes->{Args}; + if ($args && @$args && $args->[0] ne '' && $args->[0] =~ /^\d+$/) { + for (1..$args->[0]) { + push @parts, "{arg$_}"; + } + } + + my $path = '/' . join('/', @parts); + $path =~ s{/+}{/}g; + + return $path; +} + +sub _get_http_methods { + my ($class, $name) = @_; + + # Check POD-defined methods first + if (exists $_pod_cache{$name} && $_pod_cache{$name}{methods}) { + return @{$_pod_cache{$name}{methods}}; + } + + # Fall back to source code analysis + if (exists $_pod_cache{$name} && $_pod_cache{$name}{detected_methods}) { + return @{$_pod_cache{$name}{detected_methods}}; + } + + # Default to GET + return ('get'); +} + +# Infer tag from method name patterns +sub _infer_tag { + my ($method_name) = @_; + + return 'Authentication' if $method_name =~ /login|logout|auth|token/i; + return 'Member Registration' if $method_name =~ /register|signup|member|profile/i; + return 'Tools' if $method_name =~ /tool/i; + return 'Vehicles' if $method_name =~ /vehicle/i; + return 'Transactions' if $method_name =~ /transaction|payment/i; + return 'Admin' if $method_name =~ /admin/i; + + return 'Uncategorised'; +} + +sub _get_tag { + my ($class, $name) = @_; + + # Check POD-defined tag first + if (exists $_pod_cache{$name} && $_pod_cache{$name}{tag}) { + return $_pod_cache{$name}{tag}; + } + + # Fall back to 'Uncategorised' + return 'Uncategorised'; +} + +# Default categorization for endpoints without POD Tags +sub _categorize_action_default { + return 'Uncategorised'; +} + +sub _get_description { + my ($class, $name) = @_; + return $_pod_cache{$name}{description}; +} + +sub _get_signature_params { + my ($class, $name) = @_; + return $_pod_cache{$name}{signature_params}; +} + +sub _humanize_name { + my ($class, $name) = @_; + $name =~ s/_/ /g; + return ucfirst($name); +} + +sub _get_query_params { + my ($class, $name) = @_; + return $_pod_cache{$name}{params} || []; +} + +# Parse POD from all controller files in the app +sub _parse_all_controller_pods { + my ($class, $app_class) = @_; + + return if %_pod_cache; # Already parsed + + # Find the lib directory + my $app_file = $app_class; + $app_file =~ s{::}{/}g; + $app_file .= '.pm'; + + my $lib_dir; + for my $inc (@INC) { + if (-f "$inc/$app_file") { + $lib_dir = $inc; + last; + } + } + + return unless $lib_dir; + + # Find all Controller .pm files + my @controller_files; + my $wanted = sub { + return unless /\.pm$/; + return unless $File::Find::name =~ /Controller/; + push @controller_files, $File::Find::name; + }; + + File::Find::find($wanted, $lib_dir); + + # Parse each controller file + for my $file (@controller_files) { + $class->_parse_controller_pod($file); + } +} + +# Parse POD documentation from a controller file to extract parameters +sub _parse_controller_pod { + my ($class, $file) = @_; + + open my $fh, '<', $file or return; + my $content = do { local $/; <$fh> }; + close $fh; + + # Find all POD blocks with parameters followed by sub definitions + # Pattern: =head2 name ... =item param = description ... =cut ... sub name + while ($content =~ m{ + =head2\s+(\w+) # Capture the endpoint name + (.*?) # Capture POD content + =cut\s*\n # End of POD + \s*sub\s+\1 # Sub definition with same name + }gxs) { + my ($endpoint_name, $pod_content) = ($1, $2); + + # Initialize cache entry + $_pod_cache{$endpoint_name} ||= {}; + + # Parse Tags: line with validation + if ($pod_content =~ /^Tags?:\s*(.+)$/m) { + my $tag = $1; + $tag =~ s/\s+$//; + + # Validate tag: reasonable length and no weird characters + if (length($tag) > 50) { + warn "WARNING: Tag '$tag' for $endpoint_name is very long (>50 chars)\n"; + } + if ($tag =~ /[^\w\s\-&]/) { + warn "WARNING: Tag '$tag' for $endpoint_name contains unusual characters\n"; + } + + $_pod_cache{$endpoint_name}{tag} = $tag; + } else { + # Smart default: infer from method name + my $inferred_tag = _infer_tag($endpoint_name); + warn "INFO: No tag specified for $endpoint_name, using inferred tag '$inferred_tag'\n" + if $ENV{OPENAPI_DEBUG}; + $_pod_cache{$endpoint_name}{tag} = $inferred_tag; + } + + # Parse Methods: line with validation + if ($pod_content =~ /^Methods?:\s*(.+)$/m) { + my $methods_str = $1; + $methods_str =~ s/\s+$//; + my @methods = map { lc($_) } split /[,\s]+/, $methods_str; + + # Validate HTTP methods + my @valid_methods = qw(get post put delete patch options head); + my @invalid = grep { my $m = $_; !grep { $_ eq $m } @valid_methods } @methods; + if (@invalid) { + warn "ERROR: Invalid HTTP method(s) for $endpoint_name: " . join(', ', @invalid) . "\n"; + @methods = grep { my $m = $_; grep { $_ eq $m } @valid_methods } @methods; + } + + $_pod_cache{$endpoint_name}{methods} = \@methods if @methods; + } else { + # Default will be inferred by source code analysis later + warn "INFO: No methods specified for $endpoint_name, will use source code analysis\n" + if $ENV{OPENAPI_DEBUG}; + } + + # Extract description (everything that is not Tags, Methods, or param blocks) + my $desc = $pod_content; + $desc =~ s/^Tags:.*$//mg; + $desc =~ s/^Methods:.*$//mg; + $desc =~ s/=over.*//sg; + $desc =~ s/^\s+|\s+$//g; + $_pod_cache{$endpoint_name}{description} = $desc if length($desc); + + # Parse =item entries for parameters + my @params; + while ($pod_content =~ m{=item\s+(\w+)\s*(?:\(([^)]*)\))?\s*[=-]\s*(.+?)(?=\n\n|\n=|$)}gs) { + my ($param_name, $modifiers, $description) = ($1, $2 // '', $3); + $description =~ s/\s+$//; + + # Parse modifiers like "required", "uuid", "integer", etc. + my $required = ($modifiers =~ /required/i) ? JSON::true : JSON::false; + my $type = 'string'; + my $format; + + if ($modifiers =~ /\binteger\b/i) { + $type = 'integer'; + } elsif ($modifiers =~ /\bnumber\b/i) { + $type = 'number'; + } elsif ($modifiers =~ /\bboolean\b/i) { + $type = 'boolean'; + } + + if ($modifiers =~ /\buuid\b/i) { + $format = 'uuid'; + } elsif ($modifiers =~ /\bemail\b/i) { + $format = 'email'; + } + + my $param = { + name => $param_name, + in => 'query', + required => $required, + schema => { type => $type }, + description => $description, + }; + $param->{schema}{format} = $format if $format; + + push @params, $param; + } + + $_pod_cache{$endpoint_name}{params} = \@params if @params; + } + + # Source code analysis: detect HTTP methods from subroutine implementations + $class->_analyze_controller_methods($content); +} + +# Analyze controller source code to detect HTTP methods +sub _analyze_controller_methods { + my ($class, $content) = @_; + + # Find all subs with attributes (controller actions) + my @subs = $content =~ /^sub\s+(\w+)\s*:\s*[^\n]+/gm; + + for my $sub_name (@subs) { + + # Initialize cache entry + $_pod_cache{$sub_name} ||= {}; + + # Extract the subroutine body using a simple heuristic + # Look for the sub definition and capture until we see the next sub or end + my ($sub_body) = $content =~ /sub\s+$sub_name\s*:[^\n]+\{(.{1,3000}?)(?=\nsub\s+\w|\n__(?:END|DATA)__|$)/s; + next unless $sub_body; + + # Extract Parameter Names from Signature + if ($sub_body =~ /my\s*\(\s*\$self\s*,\s*\$c\s*(?:,\s*([^)]+))?\)\s*=\s*\@_/) { + my $args_str = $1; + if ($args_str) { + my @arg_names = split /\s*,\s*/, $args_str; + @arg_names = map { s/^[\$\@\%]//; $_ } @arg_names; + # print STDERR "DEBUG: Found signature for $sub_name: @arg_names\n"; + $_pod_cache{$sub_name}{signature_params} = \@arg_names if @arg_names; + } + } + + # Skip method detection if already defined + next if exists $_pod_cache{$sub_name} && $_pod_cache{$sub_name}{methods}; + + # Detect POST-specific patterns + my $has_post_patterns = 0; + my $has_form_render = 0; + + # POST-only patterns (body params, method check, callbacks, db writes) + $has_post_patterns = 1 if $sub_body =~ /->body_params/ + || $sub_body =~ /->method\s*eq\s*['"]POST['"]/i + || $sub_body =~ /connection_token/ + || $sub_body =~ /->(?:txn_do|create|update|delete)/; # DB writes imply POST + + # Form rendering patterns (indicates GET for display) + $has_form_render = 1 if $sub_body =~ /stash\s*\([^)]*template\s*=>/i + || $sub_body =~ /\$form->/; + + # Determine methods based on detected patterns + if ($has_form_render && $has_post_patterns) { + # Form with both display and submit + $_pod_cache{$sub_name}{detected_methods} = ['get', 'post']; + } elsif ($has_form_render) { + # Form display + submit pattern + $_pod_cache{$sub_name}{detected_methods} = ['get', 'post']; + } elsif ($has_post_patterns) { + # POST-only (API endpoint, callback) + $_pod_cache{$sub_name}{detected_methods} = ['post']; + } + # Otherwise, default to GET (handled in _get_http_methods) + } +} + +1; + +__END__ + +=head1 POD DOCUMENTATION FORMAT + +To document an endpoint, use this format in the controller: + + =head2 endpoint_name + + Description of the endpoint. + + Tags: Access Control + + Methods: GET, POST + + =over + + =item param_name (modifiers) - Description + + =back + + =cut + + sub endpoint_name ... + +=head2 SUPPORTED FIELDS + +=over + +=item B (optional) + +The OpenAPI tag/category for this endpoint. +If not specified, a default categorization is used. +Example: C + +=item B (optional) + +HTTP methods this endpoint accepts. Comma or space separated. +If not specified, defaults to GET. +Example: C or C + +=back + +=head2 PARAMETER MODIFIERS + +Use modifiers in parentheses after the parameter name: + +=over + +=item * C - marks parameter as required + +=item * C - adds format: uuid + +=item * C - adds format: email + +=item * C - sets type to integer + +=item * C - sets type to number + +=item * C - sets type to boolean + +=back + +=head2 COMPLETE EXAMPLE + + =head2 park + + Parks a vehicle for a member identified by their token. + + Tags: Access Control + + Methods: GET + + =over + + =item token (required) - Access token ID of the member + + =item thing (required, uuid) - GUID of the parking controller + + =back + + =cut + + sub park : Chained('base') : PathPart('park') : Args(0) { + ... + } + +=cut diff --git a/openapi.yaml b/openapi.yaml deleted file mode 100644 index 1c4191b..0000000 --- a/openapi.yaml +++ /dev/null @@ -1,1227 +0,0 @@ -openapi: 3.0.3 -info: - title: Swindon Makerspace Access System API - description: | - A (semi-)automated access system for registering new members and giving them access to the physical space. - - It provides: - - A registration form for new members to sign up - - The ability to match RFID tokens to members - - An API for controllers (e.g., the Door) to verify if an RFID token is valid - - ## Security Note - - The database stores an expected IP for each Thing controller. These are assigned as fixed IPs - to the controllers by the main network router. The API verifies that the IP of an incoming - request matches the expected IP for the claimed thing controller ID. - version: 1.0.0 - contact: - name: Swindon Makerspace - url: https://www.swindon-makerspace.org - license: - name: Perl Artistic License - url: https://opensource.org/licenses/Artistic-2.0 - -servers: - - url: http://localhost:3000 - description: Local development server - - url: https://members.swindon-makerspace.org - description: Production server - -tags: - - name: Access Control - description: Endpoints for IoT devices and access controllers - - name: Member Registration - description: Endpoints for member registration and profile management - - name: Authentication - description: Login and logout endpoints - - name: Transactions - description: Member transaction/balance endpoints - - name: Admin - description: Administrative endpoints - - name: Telegram - description: Telegram bot integration endpoints - -paths: - /: - get: - summary: Root page - description: Returns a welcome message - operationId: index - responses: - '200': - description: Welcome message - content: - text/html: - schema: - type: string - - /verify: - get: - summary: Verify access token - description: | - Given an access token (RFID) and a thing (device) GUID, check whether the person - owning the token is allowed to access the thing. Checks if payments are up-to-date - and if the member has access rights to the specific thing. - operationId: verify - tags: - - Access Control - parameters: - - name: token - in: query - required: true - description: Access token ID (e.g., RFID card ID) - schema: - type: string - - name: thing - in: query - required: true - description: GUID of the thing/device being accessed - schema: - type: string - format: uuid - responses: - '200': - description: Access verification result - content: - application/json: - schema: - oneOf: - - $ref: '#/components/schemas/VerifySuccessResponse' - - $ref: '#/components/schemas/VerifyDeniedResponse' - examples: - accessGranted: - summary: Access granted - value: - person: - name: "John Doe" - inductor: false - access: 1 - beep: 0 - cache: 1 - colour: 1 - accessDenied: - summary: Access denied - value: - access: 0 - error: "Membership expired" - colour: 35 - - /msglog: - get: - summary: Store a message log - description: | - Allows any Thing to store a message in the message_log table. - Used for device logging and debugging. - operationId: msg_log - tags: - - Access Control - parameters: - - name: thing - in: query - required: true - description: GUID of the thing controller - schema: - type: string - format: uuid - - name: msg - in: query - required: true - description: Text of the message to save - schema: - type: string - responses: - '200': - description: Log result - content: - application/json: - schema: - $ref: '#/components/schemas/LogResponse' - - /tool_access: - get: - summary: Update tool usage state - description: | - Update UsageLog/state for a tool. Called periodically by tool controllers - to track active usage time. - operationId: tool_access - tags: - - Access Control - parameters: - - name: token - in: query - required: true - description: User's access token ID - schema: - type: string - - name: thing - in: query - required: true - description: GUID of the thing controller - schema: - type: string - format: uuid - - name: msg - in: query - required: false - description: Description of what's happening - schema: - type: string - - name: state - in: query - required: true - description: 1 for on, 0 for off - schema: - type: integer - enum: [0, 1] - - name: active_time - in: query - required: true - description: Number of seconds the tool has been active - schema: - type: integer - responses: - '200': - description: Usage logged - content: - application/json: - schema: - $ref: '#/components/schemas/LogResponse' - - /induct: - get: - summary: Record induction - description: | - Records that a trainer (token_t) has inducted a student (token_s) to use a specific thing. - The request must come from the correct IP address assigned to the thing. - operationId: induct - tags: - - Access Control - parameters: - - name: token_t - in: query - required: true - description: Trainer's access token ID - schema: - type: string - - name: token_s - in: query - required: true - description: Student's access token ID - schema: - type: string - - name: thing - in: query - required: true - description: GUID of the thing/tool - schema: - type: string - format: uuid - responses: - '200': - description: Induction result - content: - application/json: - schema: - $ref: '#/components/schemas/InductResponse' - examples: - success: - summary: Induction successful - value: - allowed: 1 - person: - name: "Jane Student" - failure: - summary: Induction failed - value: - allowed: 0 - error: "Trainer not authorized" - - /assign: - get: - summary: Assign access token to member - description: | - Assigns a new access token to a member. Requires admin privileges - and must be called from the TagAssigner device. - operationId: assign - tags: - - Access Control - parameters: - - name: admin_token - in: query - required: true - description: Admin's access token ID - schema: - type: string - - name: person_id - in: query - required: true - description: ID of the person to assign the token to - schema: - type: integer - - name: thing - in: query - required: true - description: GUID of the TagAssigner device - schema: - type: string - format: uuid - - name: token_id - in: query - required: true - description: ID of the new token to assign - schema: - type: string - - name: desc - in: query - required: false - description: Description for the token - schema: - type: string - responses: - '200': - description: Assignment result - content: - application/json: - schema: - type: object - properties: - tokens: - type: integer - description: Number of tokens the member now has - message: - type: string - error: - type: string - - /park: - get: - summary: Auto-park member's vehicles - description: | - Given a member's token and thing ID, automatically registers their - vehicles for parking via the parking API. - operationId: park - tags: - - Access Control - parameters: - - name: token - in: query - required: true - description: Member's access token ID - schema: - type: string - - name: thing - in: query - required: true - description: GUID of the parking controller - schema: - type: string - format: uuid - responses: - '200': - description: Parking result - content: - application/json: - schema: - $ref: '#/components/schemas/SuccessResponse' - - /who: - get: - summary: Get person name from token - description: Returns the name of the person who owns the given token - operationId: who - tags: - - Access Control - parameters: - - name: token - in: query - required: true - description: Access token ID - schema: - type: string - responses: - '200': - description: Person name - content: - text/plain: - schema: - type: string - example: "John Doe" - - /register: - get: - summary: Display registration form - description: Displays the member registration form - operationId: register_get - tags: - - Member Registration - responses: - '200': - description: Registration form HTML - content: - text/html: - schema: - type: string - post: - summary: Submit registration - description: Process new member registration - operationId: register_post - tags: - - Member Registration - requestBody: - required: true - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PersonRegistration' - responses: - '200': - description: Registration result or form with errors - content: - text/html: - schema: - type: string - '302': - description: Redirect to add child page if has_children is set - - /add_child: - get: - summary: Display add child form - description: Form to add a child member (requires parent_id in session) - operationId: add_child_get - tags: - - Member Registration - responses: - '200': - description: Add child form - content: - text/html: - schema: - type: string - post: - summary: Submit child registration - description: Add a child member under a parent - operationId: add_child_post - tags: - - Member Registration - requestBody: - required: true - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/ChildRegistration' - responses: - '200': - description: Child added or form with errors - content: - text/html: - schema: - type: string - - /get_dues: - get: - summary: Calculate dues amount - description: | - Returns the amount of dues (in pence) that would be payable using the given - parameters. Used by the registration page to live-update dues. - operationId: get_dues - tags: - - Member Registration - parameters: - - name: dob - in: query - required: false - description: Date of birth (YYYY-MM-DD) - schema: - type: string - format: date - - name: concessionary_rate_override - in: query - required: false - description: Concession type (e.g., student, unemployed) - schema: - type: string - - name: tier - in: query - required: false - description: Membership tier (default 3) - schema: - type: integer - default: 3 - responses: - '200': - description: Dues amount in pounds - content: - text/plain: - schema: - type: number - format: float - example: 25.00 - - /login: - get: - summary: Display login page - description: Shows the login page with social login options (GitHub, Google via OneAll) - operationId: login - tags: - - Authentication - responses: - '200': - description: Login form - content: - text/html: - schema: - type: string - - /logout: - get: - summary: Logout user - description: Removes the authentication cookie and redirects to login - operationId: logout - tags: - - Authentication - responses: - '302': - description: Redirect to login page - - /oneall_login_callback: - post: - summary: OneAll login callback - description: Internal callback endpoint for OneAll social login - operationId: oneall_login_callback - tags: - - Authentication - requestBody: - required: true - content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - connection_token: - type: string - responses: - '302': - description: Redirect to profile on success, login on failure - - /profile: - get: - summary: View member profile - description: Shows the logged-in member's profile including payment status - operationId: profile - tags: - - Member Registration - security: - - cookieAuth: [] - responses: - '200': - description: Profile page - content: - text/html: - schema: - type: string - '302': - description: Redirect to login if not authenticated - - /editme: - get: - summary: Edit profile form - description: Form to edit the logged-in member's details - operationId: editme_get - tags: - - Member Registration - security: - - cookieAuth: [] - responses: - '200': - description: Edit form - content: - text/html: - schema: - type: string - post: - summary: Update profile - description: Update the logged-in member's details - operationId: editme_post - tags: - - Member Registration - security: - - cookieAuth: [] - requestBody: - required: true - content: - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PersonUpdate' - responses: - '302': - description: Redirect to profile on success - '200': - description: Form with validation errors - - /download: - get: - summary: Download member data - description: Download all data associated with the logged-in member (GDPR export) - operationId: download_data - tags: - - Member Registration - security: - - cookieAuth: [] - responses: - '200': - description: JSON data export - content: - application/json: - schema: - type: array - items: - type: object - - /deleteme: - get: - summary: Delete account confirmation - description: Shows account deletion confirmation page - operationId: delete_me_get - tags: - - Member Registration - security: - - cookieAuth: [] - responses: - '200': - description: Confirmation page - content: - text/html: - schema: - type: string - post: - summary: Delete account - description: Permanently deletes the member's account - operationId: delete_me_post - tags: - - Member Registration - security: - - cookieAuth: [] - requestBody: - required: true - content: - application/x-www-form-urlencoded: - schema: - type: object - required: - - reallyreally - properties: - reallyreally: - type: string - enum: ["yupyup"] - responses: - '302': - description: Redirect to login after deletion - - /delete_token: - get: - summary: Delete access token - description: | - Removes an access token from the logged-in member. - At least one token must remain. - operationId: delete_token - tags: - - Member Registration - security: - - cookieAuth: [] - parameters: - - name: token - in: query - required: true - description: Token ID to delete - schema: - type: string - responses: - '302': - description: Redirect to profile - - /delete_vehicle: - get: - summary: Delete vehicle - description: Removes a vehicle from the logged-in member's account - operationId: delete_vehicle - tags: - - Member Registration - security: - - cookieAuth: [] - parameters: - - name: vehicle - in: query - required: true - description: Vehicle plate registration to delete - schema: - type: string - responses: - '302': - description: Redirect to profile - - /transaction: - post: - summary: Record a transaction - description: | - Records a debit transaction for a member. Can be called either with - a user hash (from app) or with a token and thing (from IoT device). - operationId: record_transaction - tags: - - Transactions - requestBody: - required: true - content: - application/x-www-form-urlencoded: - schema: - oneOf: - - type: object - required: [hash, amount, reason] - properties: - hash: - type: string - description: User GUID hash - amount: - type: integer - description: Amount in pence - reason: - type: string - description: Transaction description - - type: object - required: [token, thing, amount, reason] - properties: - token: - type: string - description: Access token ID - thing: - type: string - format: uuid - description: Thing GUID - amount: - type: integer - description: Amount in pence - reason: - type: string - description: Transaction description - responses: - '200': - description: Transaction result - content: - application/json: - schema: - type: object - properties: - success: - type: integer - enum: [0, 1] - error: - type: string - balance: - type: integer - description: Current balance in pence - - /get_transactions/{count}/{user_hash}: - get: - summary: Get recent transactions - description: Returns the N most recent transactions for a member - operationId: get_transactions - tags: - - Transactions - parameters: - - name: count - in: path - required: true - description: Number of transactions to return - schema: - type: integer - - name: user_hash - in: path - required: true - description: User GUID hash - schema: - type: string - responses: - '200': - description: Transaction list - content: - application/json: - schema: - type: object - properties: - transactions: - type: array - items: - type: object - properties: - added_on: - type: string - format: date-time - reason: - type: string - amount: - type: integer - description: Amount in pence - balance: - type: integer - description: Current balance in pence - - /user_guid_request: - get: - summary: Request user GUID email - description: | - Sends an email to the member containing their GUID for use in the phone app. - operationId: user_guid_request - tags: - - Member Registration - parameters: - - name: userid - in: query - required: true - description: Member ID (with or without SM prefix) - schema: - type: string - example: "SM0001" - responses: - '200': - description: Request result - content: - application/json: - schema: - $ref: '#/components/schemas/SuccessResponse' - - /confirm_telegram: - get: - summary: Initiate Telegram confirmation - description: | - Called from the Telegram bot identify command. Sends an email to the - member to confirm they wish to link their Telegram account. - operationId: confirm_telegram - tags: - - Telegram - parameters: - - name: email - in: query - required: true - description: Member's email address - schema: - type: string - format: email - - name: chatid - in: query - required: true - description: Telegram chat ID - schema: - type: string - - name: username - in: query - required: false - description: Telegram username - schema: - type: string - responses: - '200': - description: Confirmation result - content: - application/json: - schema: - $ref: '#/components/schemas/SuccessResponse' - - /confirm_email: - get: - summary: Confirm email link - description: | - Processes the email confirmation link for Telegram account linking. - Stores the Telegram chat ID and username in the member's record. - operationId: confirm_email - tags: - - Telegram - parameters: - - name: token - in: query - required: true - description: Confirmation token from email - schema: - type: string - format: uuid - responses: - '302': - description: Redirect to post_confirm page - - /post_confirm: - get: - summary: Post-confirmation page - description: Shows a confirmation success page after email confirmation - operationId: post_confirm - tags: - - Telegram - parameters: - - name: type - in: query - required: false - description: Type of confirmation (telegram, induction) - schema: - type: string - responses: - '200': - description: Confirmation page - content: - text/html: - schema: - type: string - - /send_induction_acceptance: - get: - summary: Send induction acceptance email - description: Sends an email to a member to accept their tool induction - operationId: send_induction_acceptance - tags: - - Admin - parameters: - - name: tool - in: query - required: true - description: Tool GUID - schema: - type: string - format: uuid - - name: person - in: query - required: true - description: Person ID - schema: - type: integer - responses: - '200': - description: Send result - content: - application/json: - schema: - $ref: '#/components/schemas/SuccessResponse' - - /confirm_induction: - get: - summary: Confirm induction acceptance - description: Processes the induction acceptance from the email link - operationId: confirm_induction - tags: - - Admin - parameters: - - name: token - in: query - required: true - description: Confirmation token from email - schema: - type: string - format: uuid - responses: - '302': - description: Redirect to post_confirm page - - /resendemail/{id}: - get: - summary: Resend membership email - description: Resends the membership confirmation/payment details email to a member - operationId: resend_email - tags: - - Admin - parameters: - - name: id - in: path - required: true - description: Member ID - schema: - type: integer - responses: - '200': - description: Send result - content: - application/json: - schema: - type: object - properties: - message: - type: string - - /nudge_member/{id}: - get: - summary: Send payment reminder - description: | - Sends an email to an expired member asking if they stopped paying on purpose. - Only works for expired members who haven't been marked as "ended". - operationId: nudge_member - tags: - - Admin - parameters: - - name: id - in: path - required: true - description: Member ID - schema: - type: integer - responses: - '200': - description: Reminder result - content: - application/json: - schema: - type: object - properties: - message: - type: string - - /box_reminder/{id}: - get: - summary: Send member box reminder - description: Sends a reminder email about member box storage to an expired member - operationId: box_reminder - tags: - - Admin - parameters: - - name: id - in: path - required: true - description: Member ID - schema: - type: integer - responses: - '200': - description: Reminder result - content: - application/json: - schema: - type: object - properties: - message: - type: string - - /membership_status_update: - get: - summary: Generate membership status report - description: | - Collects data about current membership numbers (paying, expired, ex, - children, concessions, etc.) and emails it to the directors. - operationId: membership_status_update - tags: - - Admin - responses: - '200': - description: Membership statistics - content: - application/json: - schema: - type: object - properties: - msg_text: - type: string - recently: - type: string - additionalProperties: true - - /vehicles: - get: - summary: List valid member vehicles - description: | - Returns a plain-text list of vehicle registration plates for - currently paid-up members, sorted alphabetically. - operationId: vehicles - tags: - - Admin - security: - - cookieAuth: [] - responses: - '200': - description: Vehicle list - content: - text/plain: - schema: - type: string - example: "AB12CDE\r\nFG34HIJ\r\n" - - /membership_register: - get: - summary: View membership register - description: Displays the membership register as of a given date - operationId: membership_register - tags: - - Admin - security: - - cookieAuth: [] - parameters: - - name: at_date - in: query - required: false - description: Date to view register for (YYYY-MM-DD), defaults to today - schema: - type: string - format: date - responses: - '200': - description: Membership register page - content: - text/html: - schema: - type: string - -components: - securitySchemes: - cookieAuth: - type: apiKey - in: cookie - name: accesssystem_cookie - description: Session cookie set after OneAll login - - schemas: - VerifySuccessResponse: - type: object - required: - - access - properties: - person: - type: object - properties: - name: - type: string - inductor: - type: boolean - description: Whether the person is a trainer for this thing - access: - type: integer - enum: [1] - beep: - type: integer - enum: [0, 1] - cache: - type: integer - enum: [0, 1] - description: Whether access can be cached (0 if time-restricted) - colour: - type: integer - description: Door LED colour code - - VerifyDeniedResponse: - type: object - required: - - access - properties: - access: - type: integer - enum: [0] - error: - type: string - description: Reason for denial - colour: - type: integer - description: Door LED colour code - - LogResponse: - type: object - properties: - logged: - type: integer - enum: [0, 1] - error: - type: string - - InductResponse: - type: object - properties: - allowed: - type: integer - enum: [0, 1] - person: - type: object - properties: - name: - type: string - error: - type: string - - SuccessResponse: - type: object - properties: - success: - type: integer - enum: [0, 1] - message: - type: string - error: - type: string - - PersonRegistration: - type: object - required: - - name - - email - - address - - membership_guide - properties: - name: - type: string - description: Full name - email: - type: string - format: email - address: - type: string - description: Full postal address - dob: - type: string - format: date - description: Date of birth - tier: - type: integer - description: Membership tier - default: 3 - concessionary_rate_override: - type: string - description: Concession reason if applicable - payment_override: - type: integer - description: Custom payment amount in pence - door_colour: - type: string - description: LED colour preference - default: green - membership_guide: - type: boolean - description: Confirmed reading the membership guide - has_children: - type: boolean - description: Whether to add child members - - ChildRegistration: - type: object - required: - - name - - dob - properties: - name: - type: string - email: - type: string - format: email - dob: - type: string - format: date - more_children: - type: boolean - description: Add another child after this one - - PersonUpdate: - type: object - properties: - name: - type: string - email: - type: string - format: email - address: - type: string - tier: - type: integer - concessionary_rate_override: - type: string - payment_override: - type: integer - door_colour: - type: string diff --git a/script/generate_openapi.pl b/script/generate_openapi.pl new file mode 100644 index 0000000..dba4b1b --- /dev/null +++ b/script/generate_openapi.pl @@ -0,0 +1,157 @@ +#!/usr/bin/env perl +# +# generate_openapi.pl - Generate OpenAPI 3.1 spec from Catalyst app introspection +# +# Usage: +# CATALYST_HOME=$PWD carton exec perl script/generate_openapi.pl > openapi.yaml +# CATALYST_HOME=$PWD carton exec perl script/generate_openapi.pl --json > openapi.json +# +use strict; +use warnings; +use FindBin; +use lib "$FindBin::Bin/../lib"; +use Cwd qw(getcwd); +use Getopt::Long; +use JSON; + +# Set CATALYST_HOME if not already set +$ENV{CATALYST_HOME} ||= getcwd(); + +# Command line options +my $output_json = 0; +my $help = 0; +GetOptions( + 'json' => \$output_json, + 'help' => \$help, +) or die "Error in command line arguments\n"; + +if ($help) { + print <<'USAGE'; +Usage: generate_openapi.pl [OPTIONS] + +Generate an OpenAPI 3.1 specification from the Catalyst application. + +Options: + --json Output JSON instead of YAML + --help Show this help message + +USAGE + exit 0; +} + +# Load the OpenAPI module +use AccessSystem::API::OpenAPI; + +# Load the Catalyst app +require AccessSystem::API; +AccessSystem::API->setup_finalize(); + +# Generate the spec using the centralized module +my $spec = AccessSystem::API::OpenAPI->generate_spec_from_app('AccessSystem::API'); + +# Output +if ($output_json) { + print JSON->new->pretty->canonical->encode($spec); +} else { + print_yaml($spec); +} + +# Simple YAML output (avoiding YAML::XS dependency) +sub print_yaml { + my ($data, $indent) = @_; + $indent //= 0; + + my $prefix = ' ' x $indent; + + if (ref $data eq 'HASH') { + for my $key (sort keys %$data) { + my $value = $data->{$key}; + if (!ref $value) { + if (!defined $value) { + print "${prefix}${key}: null\n"; + } elsif ($value =~ /^[\d.]+$/ && $value !~ /^0\d/) { + print "${prefix}${key}: $value\n"; + } elsif ($value eq 'true' || $value eq 'false') { + print "${prefix}${key}: $value\n"; + } else { + # Quote strings that need it + if ($value =~ /[:[\]{}&#!|>'"%@`#,]/ || $value =~ /^\s/ || $value =~ /\s$/ || $value eq '') { + $value =~ s/'/\\'/g; + print "${prefix}${key}: '$value'\n"; + } else { + print "${prefix}${key}: $value\n"; + } + } + } elsif (ref $value eq 'ARRAY' && @$value == 0) { + print "${prefix}${key}: []\n"; + } elsif (ref $value eq 'HASH' && keys(%$value) == 0) { + print "${prefix}${key}: {}\n"; + } else { + print "${prefix}${key}:\n"; + print_yaml($value, $indent + 1); + } + } + } elsif (ref $data eq 'ARRAY') { + for my $item (@$data) { + if (!ref $item) { + print "${prefix}- $item\n"; + } elsif (ref $item eq 'HASH' && keys(%$item) <= 2) { + # Compact format for small hashes + my @pairs; + for my $k (sort keys %$item) { + my $v = $item->{$k}; + if (!ref $v) { + push @pairs, "$k: $v"; + } + } + if (@pairs == keys(%$item)) { + print "${prefix}- { " . join(', ', @pairs) . " }\n"; + } else { + print "${prefix}-\n"; + print_yaml($item, $indent + 1); + } + } else { + print "${prefix}-\n"; + print_yaml($item, $indent + 1); + } + } + } elsif (ref $data eq 'JSON::PP::Boolean' || ref $data eq 'JSON::XS::Boolean') { + print $data ? 'true' : 'false'; + } +} + +__END__ + +=head1 NAME + +generate_openapi.pl - Generate OpenAPI specification from Catalyst app + +=head1 SYNOPSIS + + # Generate YAML (default) + CATALYST_HOME=$PWD carton exec perl script/generate_openapi.pl > openapi.yaml + + # Generate JSON + CATALYST_HOME=$PWD carton exec perl script/generate_openapi.pl --json > openapi.json + +=head1 DESCRIPTION + +This script uses the AccessSystem::API::OpenAPI module to generate an OpenAPI +3.1 specification by introspecting the Catalyst application's dispatcher and +parsing POD documentation from controller files. + +=head1 OPTIONS + +=over 4 + +=item --json + +Output JSON instead of YAML + +=item --help + +Show help message + +=back + +=cut From 636a70b5afe881caa118afac470400b258a15af4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C3=96st=C3=B6r?= Date: Sun, 18 Jan 2026 23:30:11 +0000 Subject: [PATCH 10/10] Add OpenAPI specification validation tests and update documentation. --- README.md | 21 +++++++ t/openapi_validation.t | 135 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 t/openapi_validation.t diff --git a/README.md b/README.md index 3555c9b..62f195b 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,27 @@ You can inspect the generated spec from the command line: carton exec perl script/generate_openapi.pl > openapi.yaml +### Testing + +Run the automated test suite: + + carton exec prove -lv t/ + +Run OpenAPI validation tests specifically: + + carton exec prove -lv t/openapi_validation.t + +**In Docker:** + + # Build test image + docker build --target test -t accesssystem-test . + + # Run all tests + docker run --rm accesssystem-test + + # Run OpenAPI validation only + docker run --rm accesssystem-test carton exec prove -lv /app/t/openapi_validation.t + Security & DPA -------------- diff --git a/t/openapi_validation.t b/t/openapi_validation.t new file mode 100644 index 0000000..5ac6b77 --- /dev/null +++ b/t/openapi_validation.t @@ -0,0 +1,135 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use Test::More; +use Cwd qw(getcwd); +use JSON; + +# Set CATALYST_HOME so config is loaded correctly +$ENV{CATALYST_HOME} = getcwd(); + +# Use Catalyst::Test to ensure the app is loaded +use Catalyst::Test 'AccessSystem::API'; + +# Load the OpenAPI module +use_ok('AccessSystem::API::OpenAPI'); + +# Load the app context to get the Catalyst instance +my $app = AccessSystem::API->new(); + +# Generate OpenAPI spec using the app's dispatcher +my $spec = AccessSystem::API::OpenAPI->generate_spec($app); + +# Basic spec structure tests +ok($spec, "OpenAPI spec generated"); +ok($spec->{openapi}, "Spec has openapi version"); +ok($spec->{info}, "Spec has info section"); +ok($spec->{paths}, "Spec has paths"); +ok($spec->{components}, "Spec has components"); + +# Count endpoints +my $endpoint_count = scalar keys %{$spec->{paths}}; +cmp_ok($endpoint_count, '>', 0, "Has at least one endpoint"); +diag("Testing $endpoint_count endpoints"); + +# Track validation issues +my @warnings; +my @errors; + +# Test each endpoint +for my $path (sort keys %{$spec->{paths}}) { + for my $method (sort keys %{$spec->{paths}{$path}}) { + my $op = $spec->{paths}{$path}{$method}; + my $endpoint_id = "$method $path"; + + # Required fields + ok($op->{operationId}, "$endpoint_id has operationId") + or push @errors, "$endpoint_id missing operationId"; + ok($op->{summary}, "$endpoint_id has summary") + or push @errors, "$endpoint_id missing summary"; + ok($op->{tags} && ref($op->{tags}) eq 'ARRAY', "$endpoint_id has tags array") + or push @errors, "$endpoint_id missing/invalid tags"; + ok($op->{responses}, "$endpoint_id has responses") + or push @errors, "$endpoint_id missing responses"; + + # Tag validation (sanity checks) + if ($op->{tags} && @{$op->{tags}}) { + my $tag = $op->{tags}[0]; + if (length($tag) > 50) { + push @warnings, "$endpoint_id tag is very long (>50 chars): $tag"; + } + if ($tag =~ /[^\w\s\-&]/) { + push @warnings, "$endpoint_id tag contains unusual characters: $tag"; + } + } + + # HTTP method validation + ok($method =~ /^(get|post|put|delete|patch|options|head)$/i, + "$endpoint_id has valid HTTP method") + or push @errors, "$endpoint_id has invalid method: $method"; + + # Parameter validation + if ($op->{parameters}) { + ok(ref($op->{parameters}) eq 'ARRAY', + "$endpoint_id parameters is an array") + or next; + + for my $param (@{$op->{parameters}}) { + # Required parameter fields + ok($param->{name}, "$endpoint_id parameter has name") + or push @errors, "$endpoint_id has parameter without name"; + + ok($param->{in}, "$endpoint_id parameter '$param->{name}' has 'in' field") + or push @errors, "$endpoint_id parameter $param->{name} missing 'in'"; + + ok(exists $param->{required}, + "$endpoint_id parameter '$param->{name}' has 'required' field") + or push @warnings, "$endpoint_id parameter $param->{name} missing 'required'"; + + ok($param->{schema}, "$endpoint_id parameter '$param->{name}' has schema") + or push @errors, "$endpoint_id parameter $param->{name} missing schema"; + + # Validate 'in' values + if ($param->{in}) { + ok($param->{in} =~ /^(query|path|header|cookie)$/, + "$endpoint_id parameter '$param->{name}' has valid 'in' value") + or push @errors, "$endpoint_id parameter $param->{name} has invalid 'in': $param->{in}"; + } + + # Path parameters must be required + if ($param->{in} && $param->{in} eq 'path') { + ok($param->{required}, + "$endpoint_id path parameter '$param->{name}' is required") + or push @errors, "$endpoint_id path param $param->{name} not marked required"; + } + } + } + + # Check for description (optional but recommended) + if (!$op->{description}) { + push @warnings, "$endpoint_id missing description"; + } + } +} + +# Report validation issues +if (@errors) { + diag("\n=== ERRORS ==="); + diag(" $_") for @errors; +} + +if (@warnings) { + diag("\n=== WARNINGS ==="); + diag(" $_") for @warnings; +} + +# Summary +diag("\n=== SUMMARY ==="); +diag("Endpoints: $endpoint_count"); +diag("Errors: " . scalar(@errors)); +diag("Warnings: " . scalar(@warnings)); + +# Fail if there are errors, but warnings are OK +is(scalar(@errors), 0, "No validation errors"); + +done_testing();