diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 6c9be79..f260605 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,53 +1,88 @@
-# 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 \
- && cpanm -S Carton \
-# for Perl::LanguageServer
+ 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 \
+ 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 \
+ 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 \
+ libanyevent-perl \
+ libclass-refresh-perl \
+ libio-aio-perl \
+ libpadwalker-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/.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/.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 }}
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
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/README.md b/README.md
index 45c7b9e..62f195b 100644
--- a/README.md
+++ b/README.md
@@ -200,6 +200,76 @@ 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
+
+### 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/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/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/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/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
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
new file mode 100644
index 0000000..4b358f2
--- /dev/null
+++ b/t/lib/AccessSystem/Fixtures.pm
@@ -0,0 +1,183 @@
+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 => '{}',
+ },
+ {
+ 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;
+}
+
+=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
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();
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..e725cff
--- /dev/null
+++ b/test-deploy/docker-compose.yaml
@@ -0,0 +1,34 @@
+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"