mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-06-16 08:05:00 +02:00
feat: add OpenAPI contract check script and integrate into pre-commit hooks
This commit is contained in:
@@ -44,6 +44,14 @@ repos:
|
||||
# Test suite gate
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: openapi-contract-check
|
||||
name: OpenAPI contract check
|
||||
entry: python3 scripts/check_openapi_contract.py
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
files: ^(repeater/web/.*\.py|repeater/web/openapi\.yaml)$
|
||||
|
||||
- id: pytest
|
||||
name: pytest
|
||||
entry: ./scripts/precommit-pytest.sh
|
||||
|
||||
+939
-19
@@ -1304,20 +1304,20 @@ paths:
|
||||
type: object
|
||||
|
||||
/advert:
|
||||
get:
|
||||
delete:
|
||||
tags: [Adverts]
|
||||
summary: Get specific advert
|
||||
description: Retrieve details of a specific advertisement
|
||||
summary: Delete specific advert
|
||||
description: Delete a specific advertisement by ID
|
||||
parameters:
|
||||
- name: advert_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: Advert ID to retrieve
|
||||
description: Advert ID to delete
|
||||
responses:
|
||||
'200':
|
||||
description: Advert details
|
||||
description: Advert deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1421,20 +1421,6 @@ paths:
|
||||
# Network Policy
|
||||
# ============================================================================
|
||||
/unscoped_flood_policy:
|
||||
get:
|
||||
tags: [Network Policy]
|
||||
summary: Get unscoped flood policy
|
||||
description: Retrieve current network flood policy configuration
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- ApiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Current policy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
post:
|
||||
tags: [Network Policy]
|
||||
summary: Update unscoped flood policy
|
||||
@@ -2457,6 +2443,940 @@ paths:
|
||||
deleted_count:
|
||||
type: integer
|
||||
|
||||
/needs_setup:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Check setup wizard status
|
||||
responses:
|
||||
'200':
|
||||
description: Setup status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/site_info:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get site and host info
|
||||
responses:
|
||||
'200':
|
||||
description: Site info
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/hardware_options:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get supported hardware options
|
||||
responses:
|
||||
'200':
|
||||
description: Hardware options
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/radio_presets:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get radio presets
|
||||
responses:
|
||||
'200':
|
||||
description: Preset list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/serial_ports:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: List available serial ports
|
||||
responses:
|
||||
'200':
|
||||
description: Serial ports
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/setup_wizard:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Submit setup wizard payload
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Setup applied
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/check_pymc_console:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Check pyMC console availability
|
||||
responses:
|
||||
'200':
|
||||
description: Console status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/mqtt_status:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get MQTT runtime status
|
||||
responses:
|
||||
'200':
|
||||
description: MQTT status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/broker_presets:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: List MQTT broker presets
|
||||
responses:
|
||||
'200':
|
||||
description: Broker presets
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/update_web_config:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Update web configuration
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- ApiKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Web config updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuccessResponse'
|
||||
|
||||
/update_mqtt_config:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Update MQTT configuration
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- ApiKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: MQTT config updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuccessResponse'
|
||||
|
||||
/update_advert_rate_limit_config:
|
||||
post:
|
||||
tags: [Adverts]
|
||||
summary: Update advert rate limit configuration
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- ApiKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Advert rate limit config updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuccessResponse'
|
||||
|
||||
/bulk_packets:
|
||||
get:
|
||||
tags: [Packets]
|
||||
summary: Fetch packets in bulk
|
||||
parameters:
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 1000
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
- name: start_timestamp
|
||||
in: query
|
||||
schema:
|
||||
type: number
|
||||
- name: end_timestamp
|
||||
in: query
|
||||
schema:
|
||||
type: number
|
||||
responses:
|
||||
'200':
|
||||
description: Bulk packet result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/airtime_data:
|
||||
get:
|
||||
tags: [Packets]
|
||||
summary: Get lightweight airtime packet rows
|
||||
parameters:
|
||||
- name: start_timestamp
|
||||
in: query
|
||||
schema:
|
||||
type: number
|
||||
- name: end_timestamp
|
||||
in: query
|
||||
schema:
|
||||
type: number
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 50000
|
||||
responses:
|
||||
'200':
|
||||
description: Airtime data rows
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/airtime_chart_data:
|
||||
get:
|
||||
tags: [Charts]
|
||||
summary: Get server-aggregated airtime chart buckets
|
||||
parameters:
|
||||
- name: start_timestamp
|
||||
in: query
|
||||
schema:
|
||||
type: number
|
||||
- name: end_timestamp
|
||||
in: query
|
||||
schema:
|
||||
type: number
|
||||
- name: bucket_seconds
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 60
|
||||
- name: sf
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 9
|
||||
- name: bw_hz
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 62500
|
||||
- name: cr
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 5
|
||||
- name: preamble
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 17
|
||||
responses:
|
||||
'200':
|
||||
description: Airtime buckets
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/adverts_count_by_contact_type:
|
||||
get:
|
||||
tags: [Adverts]
|
||||
summary: Get advert count for contact type
|
||||
parameters:
|
||||
- name: contact_type
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: hours
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Advert count
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/advert_rate_limit_stats:
|
||||
get:
|
||||
tags: [Adverts]
|
||||
summary: Get advert rate-limit runtime stats
|
||||
responses:
|
||||
'200':
|
||||
description: Rate-limit stats
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/crc_error_count:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get CRC error count
|
||||
parameters:
|
||||
- name: hours
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 24
|
||||
responses:
|
||||
'200':
|
||||
description: CRC error count
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/crc_error_history:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get CRC error history
|
||||
parameters:
|
||||
- name: hours
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 24
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: CRC error records
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/memory_debug:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get memory diagnostics
|
||||
responses:
|
||||
'200':
|
||||
description: Memory diagnostics
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Start/stop memory diagnostics tracing
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
enum: [start, stop]
|
||||
responses:
|
||||
'200':
|
||||
description: Tracing state changed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/config_export:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Export configuration
|
||||
parameters:
|
||||
- name: include_secrets
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: Exported config payload
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/config_import:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Import configuration
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Import result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/identity_export:
|
||||
get:
|
||||
tags: [Identities]
|
||||
summary: Export repeater identity key
|
||||
responses:
|
||||
'200':
|
||||
description: Identity export
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/generate_vanity_key:
|
||||
post:
|
||||
tags: [Identities]
|
||||
summary: Generate vanity identity key
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [prefix]
|
||||
properties:
|
||||
prefix:
|
||||
type: string
|
||||
apply:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: Vanity key generation result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/db_stats:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get database statistics
|
||||
responses:
|
||||
'200':
|
||||
description: Database stats
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/db_purge:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Purge database tables
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Table purge result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/db_vacuum:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Vacuum SQLite database
|
||||
responses:
|
||||
'200':
|
||||
description: Vacuum result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/docs:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Serve Swagger UI docs page
|
||||
responses:
|
||||
'200':
|
||||
description: HTML docs page
|
||||
|
||||
/api/auth/tokens:
|
||||
get:
|
||||
tags: [Authentication]
|
||||
summary: List API tokens (alias path)
|
||||
responses:
|
||||
'200':
|
||||
description: Token list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
post:
|
||||
tags: [Authentication]
|
||||
summary: Create API token (alias path)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Token created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/api/auth/tokens/{token_id}:
|
||||
delete:
|
||||
tags: [Authentication]
|
||||
summary: Revoke API token (alias path)
|
||||
parameters:
|
||||
- name: token_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Token revoked
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: List companion bridge instances
|
||||
responses:
|
||||
'200':
|
||||
description: Companion instances
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/self_info:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get local companion identity info
|
||||
responses:
|
||||
'200':
|
||||
description: Companion identity
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/contacts:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: List companion contacts
|
||||
responses:
|
||||
'200':
|
||||
description: Contacts
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/contact:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get one companion contact
|
||||
parameters:
|
||||
- name: pub_key
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Contact detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/import_repeater_contacts:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Import repeater adverts into companion contacts
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Import result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/channels:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: List companion channels
|
||||
responses:
|
||||
'200':
|
||||
description: Channels
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/stats:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get companion stats
|
||||
responses:
|
||||
'200':
|
||||
description: Companion stats
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/send_text:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Send direct text message via companion
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Send result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/send_channel_message:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Send channel message via companion
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Send result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/login:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Initiate companion login flow
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Login result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/request_status:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Request companion status frame
|
||||
responses:
|
||||
'200':
|
||||
description: Request accepted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/request_telemetry:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Request companion telemetry frame
|
||||
responses:
|
||||
'200':
|
||||
description: Request accepted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/send_command:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Send command to companion
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Command result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/reset_path:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Reset companion route/path state
|
||||
responses:
|
||||
'200':
|
||||
description: Path reset
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/set_advert_name:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Set companion advert name
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Name updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/set_advert_location:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Set companion advert location
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Location updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/companion/events:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Stream companion events (SSE)
|
||||
responses:
|
||||
'200':
|
||||
description: Event stream
|
||||
|
||||
/update/status:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get update service status
|
||||
responses:
|
||||
'200':
|
||||
description: Update status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/update/check:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Trigger or fetch update check
|
||||
responses:
|
||||
'200':
|
||||
description: Check result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Trigger update check
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Check started/result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/update/install:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Install available update
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Install started
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/update/progress:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Stream update progress (SSE)
|
||||
responses:
|
||||
'200':
|
||||
description: Progress event stream
|
||||
|
||||
/update/channels:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: List update channels
|
||||
responses:
|
||||
'200':
|
||||
description: Available channels
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/update/set_channel:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Set update channel
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Channel changed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/update/changelog:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Get update changelog
|
||||
responses:
|
||||
'200':
|
||||
description: Changelog content
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/cli:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Execute repeater CLI command
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- ApiKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [command]
|
||||
properties:
|
||||
command:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: CLI command result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
components:
|
||||
schemas:
|
||||
SuccessResponse:
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check OpenAPI contract coverage against CherryPy exposed endpoints.
|
||||
|
||||
This check enforces both directions:
|
||||
- every OpenAPI path must exist in code
|
||||
- every API endpoint in code must exist in OpenAPI OR be explicitly allowlisted
|
||||
|
||||
Method checks are warning-only by default and can be made strict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
WEB_DIR = ROOT / "repeater" / "web"
|
||||
OPENAPI_PATH = WEB_DIR / "openapi.yaml"
|
||||
ALLOWLIST_PATH = ROOT / "scripts" / "openapi_contract_allowlist.yaml"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteInfo:
|
||||
methods: set[str]
|
||||
confident: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class Allowlist:
|
||||
exact: set[str]
|
||||
prefixes: tuple[str, ...]
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
path = path.strip()
|
||||
if not path:
|
||||
return "/"
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
path = re.sub(r"/{2,}", "/", path)
|
||||
path = re.sub(r"\{[^/}]+\}", "{}", path)
|
||||
if len(path) > 1 and path.endswith("/"):
|
||||
path = path[:-1]
|
||||
return path
|
||||
|
||||
|
||||
def _load_openapi() -> dict[str, set[str]]:
|
||||
with OPENAPI_PATH.open("r", encoding="utf-8") as f:
|
||||
doc = yaml.safe_load(f) or {}
|
||||
|
||||
paths = doc.get("paths", {})
|
||||
out: dict[str, set[str]] = {}
|
||||
for raw_path, ops in paths.items():
|
||||
if not isinstance(ops, dict):
|
||||
continue
|
||||
methods = {
|
||||
m.lower()
|
||||
for m in ops.keys()
|
||||
if m.lower() in {"get", "post", "put", "delete", "patch", "options", "head"}
|
||||
}
|
||||
# We do not enforce OPTIONS/HEAD in this checker.
|
||||
methods.discard("options")
|
||||
methods.discard("head")
|
||||
out[_normalize_path(str(raw_path))] = methods
|
||||
return out
|
||||
|
||||
|
||||
def _load_allowlist() -> Allowlist:
|
||||
if not ALLOWLIST_PATH.exists():
|
||||
return Allowlist(exact=set(), prefixes=tuple())
|
||||
|
||||
with ALLOWLIST_PATH.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
exact_raw = data.get("exact_paths", [])
|
||||
prefix_raw = data.get("path_prefixes", [])
|
||||
|
||||
exact = {_normalize_path(str(p)) for p in exact_raw if str(p).strip()}
|
||||
prefixes = tuple(_normalize_path(str(p)) for p in prefix_raw if str(p).strip())
|
||||
return Allowlist(exact=exact, prefixes=prefixes)
|
||||
|
||||
|
||||
def _is_allowlisted(path: str, allowlist: Allowlist) -> bool:
|
||||
if path in allowlist.exact:
|
||||
return True
|
||||
for prefix in allowlist.prefixes:
|
||||
if path == prefix or path.startswith(prefix + "/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_cherrypy_request_method_expr(node: ast.AST) -> bool:
|
||||
# cherrypy.request.method
|
||||
return (
|
||||
isinstance(node, ast.Attribute)
|
||||
and node.attr == "method"
|
||||
and isinstance(node.value, ast.Attribute)
|
||||
and node.value.attr == "request"
|
||||
and isinstance(node.value.value, ast.Name)
|
||||
and node.value.value.id == "cherrypy"
|
||||
)
|
||||
|
||||
|
||||
def _extract_method_strings(node: ast.AST) -> set[str]:
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return {node.value.upper()}
|
||||
if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
|
||||
vals: set[str] = set()
|
||||
for e in node.elts:
|
||||
vals |= _extract_method_strings(e)
|
||||
return vals
|
||||
return set()
|
||||
|
||||
|
||||
def _infer_methods(fn: ast.FunctionDef) -> tuple[set[str], bool]:
|
||||
methods: set[str] = set()
|
||||
confidence = False
|
||||
has_require_post = False
|
||||
saw_method_compare = False
|
||||
|
||||
for node in ast.walk(fn):
|
||||
if isinstance(node, ast.Call):
|
||||
# self._require_post()
|
||||
if (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "_require_post"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "self"
|
||||
):
|
||||
has_require_post = True
|
||||
methods.add("POST")
|
||||
confidence = True
|
||||
|
||||
if not isinstance(node, ast.Compare):
|
||||
continue
|
||||
if not _is_cherrypy_request_method_expr(node.left):
|
||||
continue
|
||||
saw_method_compare = True
|
||||
if not node.ops or not node.comparators:
|
||||
continue
|
||||
|
||||
op = node.ops[0]
|
||||
rhs_vals = _extract_method_strings(node.comparators[0])
|
||||
if not rhs_vals:
|
||||
continue
|
||||
|
||||
# Treat equality and inequality guards as declared allowed methods.
|
||||
if isinstance(op, (ast.Eq, ast.In, ast.NotEq, ast.NotIn)):
|
||||
methods |= rhs_vals
|
||||
confidence = True
|
||||
|
||||
methods.discard("OPTIONS")
|
||||
methods.discard("HEAD")
|
||||
|
||||
# If a handler branches on request.method but is not explicitly POST-only,
|
||||
# CherryPy's default method for uncovered branches is typically GET.
|
||||
if saw_method_compare and not has_require_post and methods and "POST" in methods:
|
||||
methods.add("GET")
|
||||
|
||||
if not methods:
|
||||
return {"GET"}, False
|
||||
return methods, confidence
|
||||
|
||||
|
||||
def _has_expose_decorator(fn: ast.FunctionDef) -> bool:
|
||||
for d in fn.decorator_list:
|
||||
# @cherrypy.expose
|
||||
if isinstance(d, ast.Attribute):
|
||||
if (
|
||||
d.attr == "expose"
|
||||
and isinstance(d.value, ast.Name)
|
||||
and d.value.id == "cherrypy"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _fn_params(fn: ast.FunctionDef) -> list[str]:
|
||||
params = [a.arg for a in fn.args.args if a.arg != "self"]
|
||||
return [p for p in params if p not in {"kwargs", "args"}]
|
||||
|
||||
|
||||
def _candidate_suffixes(fn: ast.FunctionDef) -> list[str]:
|
||||
name = fn.name
|
||||
params = _fn_params(fn)
|
||||
|
||||
if name == "index":
|
||||
return [""]
|
||||
|
||||
if name == "default":
|
||||
if params:
|
||||
return ["/{}"]
|
||||
return ["/{path}"]
|
||||
|
||||
base = f"/{name}"
|
||||
# Keep named endpoints canonical. Parameters are often query parameters,
|
||||
# so adding path-segment variants here produces false positives.
|
||||
return [base]
|
||||
|
||||
|
||||
def _collect_class_routes(module_path: Path, class_name: str, prefixes: list[str]) -> dict[str, RouteInfo]:
|
||||
tree = ast.parse(module_path.read_text(encoding="utf-8"), filename=str(module_path))
|
||||
cls = next(
|
||||
(
|
||||
n
|
||||
for n in tree.body
|
||||
if isinstance(n, ast.ClassDef) and n.name == class_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if cls is None:
|
||||
return {}
|
||||
|
||||
routes: dict[str, RouteInfo] = {}
|
||||
for node in cls.body:
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
if class_name == "APIEndpoints" and node.name == "default":
|
||||
# Catch-all handlers are not part of API contract surface.
|
||||
continue
|
||||
if not _has_expose_decorator(node):
|
||||
continue
|
||||
|
||||
methods, confident = _infer_methods(node)
|
||||
suffixes = _candidate_suffixes(node)
|
||||
|
||||
for prefix in prefixes:
|
||||
for suffix in suffixes:
|
||||
path = _normalize_path(prefix + suffix)
|
||||
cur = routes.get(path)
|
||||
if cur is None:
|
||||
routes[path] = RouteInfo(methods=set(methods), confident=confident)
|
||||
else:
|
||||
cur.methods |= methods
|
||||
cur.confident = cur.confident or confident
|
||||
return routes
|
||||
|
||||
|
||||
def _collect_routes() -> dict[str, RouteInfo]:
|
||||
route_map: dict[str, RouteInfo] = {}
|
||||
|
||||
class_specs = [
|
||||
# /api/* methods are described in OpenAPI as /<endpoint>
|
||||
(WEB_DIR / "api_endpoints.py", "APIEndpoints", [""]),
|
||||
# Nested /api/companion/* endpoints are described as /companion/*.
|
||||
(WEB_DIR / "companion_endpoints.py", "CompanionAPIEndpoints", ["/companion"]),
|
||||
# Nested /api/update/* endpoints are described as /update/* when documented.
|
||||
(WEB_DIR / "update_endpoints.py", "UpdateAPIEndpoints", ["/update"]),
|
||||
# Auth top-level endpoints are mounted at /auth/*
|
||||
(WEB_DIR / "auth_endpoints.py", "AuthEndpoints", ["/auth"]),
|
||||
# Token sub-resource is exposed both under /auth and /api/auth in current routing.
|
||||
(WEB_DIR / "auth_endpoints.py", "TokensAPIEndpoint", ["/auth/tokens", "/api/auth/tokens"]),
|
||||
]
|
||||
|
||||
for file_path, class_name, prefixes in class_specs:
|
||||
class_routes = _collect_class_routes(file_path, class_name, prefixes)
|
||||
for path, info in class_routes.items():
|
||||
cur = route_map.get(path)
|
||||
if cur is None:
|
||||
route_map[path] = info
|
||||
else:
|
||||
cur.methods |= info.methods
|
||||
cur.confident = cur.confident or info.confident
|
||||
|
||||
return route_map
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Check OpenAPI contract coverage.")
|
||||
parser.add_argument(
|
||||
"--strict-methods",
|
||||
action="store_true",
|
||||
help="Fail when inferred HTTP methods differ from OpenAPI methods.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not OPENAPI_PATH.exists():
|
||||
print(f"ERROR: OpenAPI spec not found at {OPENAPI_PATH}")
|
||||
return 1
|
||||
|
||||
spec = _load_openapi()
|
||||
allowlist = _load_allowlist()
|
||||
code_routes = _collect_routes()
|
||||
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
for path, spec_methods in sorted(spec.items()):
|
||||
code = code_routes.get(path)
|
||||
if code is None:
|
||||
errors.append(f"Missing endpoint in code for OpenAPI path: {path}")
|
||||
continue
|
||||
|
||||
if spec_methods and code.confident:
|
||||
code_methods = {m.lower() for m in code.methods}
|
||||
missing_methods = sorted(m for m in spec_methods if m not in code_methods)
|
||||
if missing_methods:
|
||||
msg = (
|
||||
f"Method mismatch for {path}: OpenAPI has {sorted(spec_methods)}, "
|
||||
f"code inference has {sorted(code_methods)}"
|
||||
)
|
||||
if args.strict_methods:
|
||||
errors.append(msg)
|
||||
else:
|
||||
warnings.append(msg)
|
||||
|
||||
# Enforce code -> OpenAPI (unless allowlisted)
|
||||
for path in sorted(code_routes.keys()):
|
||||
if path in spec:
|
||||
continue
|
||||
if _is_allowlisted(path, allowlist):
|
||||
continue
|
||||
errors.append(
|
||||
f"Undocumented endpoint in code (not in OpenAPI and not allowlisted): {path}"
|
||||
)
|
||||
|
||||
if warnings:
|
||||
print("OpenAPI contract warnings:")
|
||||
for w in warnings:
|
||||
print(f"- {w}")
|
||||
|
||||
if errors:
|
||||
print("OpenAPI contract check failed:")
|
||||
for e in errors:
|
||||
print(f"- {e}")
|
||||
return 1
|
||||
|
||||
print("OpenAPI contract check passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user