Compare commits

...

3 Commits

Author SHA1 Message Date
dgtlmoon
3acf9fa60d BUmp docs 2025-08-25 14:39:00 +02:00
dgtlmoon
001d294654 Validate API calls against our YAML 2025-08-25 14:34:51 +02:00
dgtlmoon
994d17fc7a Improvements to schema and adding YAML validation 2025-08-25 13:41:59 +02:00
11 changed files with 243 additions and 153 deletions

View File

@@ -15,6 +15,10 @@ jobs:
ruff check . --select E9,F63,F7,F82 ruff check . --select E9,F63,F7,F82
# Complete check with errors treated as warnings # Complete check with errors treated as warnings
ruff check . --exit-zero ruff check . --exit-zero
- name: Validate OpenAPI spec
run: |
pip install openapi-spec-validator
python3 -c "from openapi_spec_validator import validate_spec; import yaml; validate_spec(yaml.safe_load(open('docs/api-spec.yaml')))"
test-application-3-10: test-application-3-10:
needs: lint-code needs: lint-code

View File

@@ -3,7 +3,7 @@ from changedetectionio.strtobool import strtobool
from flask_restful import abort, Resource from flask_restful import abort, Resource
from flask import request from flask import request
import validators import validators
from . import auth from . import auth, validate_openapi_request
class Import(Resource): class Import(Resource):
@@ -12,6 +12,7 @@ class Import(Resource):
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
@auth.check_token @auth.check_token
@validate_openapi_request('importWatches')
def post(self): def post(self):
"""Import a list of watched URLs.""" """Import a list of watched URLs."""

View File

@@ -1,9 +1,7 @@
from flask_expects_json import expects_json from flask_expects_json import expects_json
from flask_restful import Resource from flask_restful import Resource, abort
from . import auth
from flask_restful import abort, Resource
from flask import request from flask import request
from . import auth from . import auth, validate_openapi_request
from . import schema_create_notification_urls, schema_delete_notification_urls from . import schema_create_notification_urls, schema_delete_notification_urls
class Notifications(Resource): class Notifications(Resource):
@@ -12,6 +10,7 @@ class Notifications(Resource):
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
@auth.check_token @auth.check_token
@validate_openapi_request('getNotifications')
def get(self): def get(self):
"""Return Notification URL List.""" """Return Notification URL List."""
@@ -22,6 +21,7 @@ class Notifications(Resource):
}, 200 }, 200
@auth.check_token @auth.check_token
@validate_openapi_request('addNotifications')
@expects_json(schema_create_notification_urls) @expects_json(schema_create_notification_urls)
def post(self): def post(self):
"""Create Notification URLs.""" """Create Notification URLs."""
@@ -49,6 +49,7 @@ class Notifications(Resource):
return {'notification_urls': added_urls}, 201 return {'notification_urls': added_urls}, 201
@auth.check_token @auth.check_token
@validate_openapi_request('replaceNotifications')
@expects_json(schema_create_notification_urls) @expects_json(schema_create_notification_urls)
def put(self): def put(self):
"""Replace Notification URLs.""" """Replace Notification URLs."""
@@ -71,6 +72,7 @@ class Notifications(Resource):
return {'notification_urls': clean_urls}, 200 return {'notification_urls': clean_urls}, 200
@auth.check_token @auth.check_token
@validate_openapi_request('deleteNotifications')
@expects_json(schema_delete_notification_urls) @expects_json(schema_delete_notification_urls)
def delete(self): def delete(self):
"""Delete Notification URLs.""" """Delete Notification URLs."""

View File

@@ -1,6 +1,6 @@
from flask_restful import Resource, abort from flask_restful import Resource, abort
from flask import request from flask import request
from . import auth from . import auth, validate_openapi_request
class Search(Resource): class Search(Resource):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -8,6 +8,7 @@ class Search(Resource):
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
@auth.check_token @auth.check_token
@validate_openapi_request('searchWatches')
def get(self): def get(self):
"""Search for watches by URL or title text.""" """Search for watches by URL or title text."""
query = request.args.get('q', '').strip() query = request.args.get('q', '').strip()

View File

@@ -1,5 +1,5 @@
from flask_restful import Resource from flask_restful import Resource
from . import auth from . import auth, validate_openapi_request
class SystemInfo(Resource): class SystemInfo(Resource):
@@ -9,6 +9,7 @@ class SystemInfo(Resource):
self.update_q = kwargs['update_q'] self.update_q = kwargs['update_q']
@auth.check_token @auth.check_token
@validate_openapi_request('getSystemInfo')
def get(self): def get(self):
"""Return system info.""" """Return system info."""
import time import time

View File

@@ -7,7 +7,7 @@ from flask import request
from . import auth from . import auth
# Import schemas from __init__.py # Import schemas from __init__.py
from . import schema_tag, schema_create_tag, schema_update_tag from . import schema_tag, schema_create_tag, schema_update_tag, validate_openapi_request
class Tag(Resource): class Tag(Resource):
@@ -19,6 +19,7 @@ class Tag(Resource):
# Get information about a single tag # Get information about a single tag
# curl http://localhost:5000/api/v1/tag/<string:uuid> # curl http://localhost:5000/api/v1/tag/<string:uuid>
@auth.check_token @auth.check_token
@validate_openapi_request('getTag')
def get(self, uuid): def get(self, uuid):
"""Get data for a single tag/group, toggle notification muting, or recheck all.""" """Get data for a single tag/group, toggle notification muting, or recheck all."""
from copy import deepcopy from copy import deepcopy
@@ -50,6 +51,7 @@ class Tag(Resource):
return tag return tag
@auth.check_token @auth.check_token
@validate_openapi_request('deleteTag')
def delete(self, uuid): def delete(self, uuid):
"""Delete a tag/group and remove it from all watches.""" """Delete a tag/group and remove it from all watches."""
if not self.datastore.data['settings']['application']['tags'].get(uuid): if not self.datastore.data['settings']['application']['tags'].get(uuid):
@@ -66,6 +68,7 @@ class Tag(Resource):
return 'OK', 204 return 'OK', 204
@auth.check_token @auth.check_token
@validate_openapi_request('updateTag')
@expects_json(schema_update_tag) @expects_json(schema_update_tag)
def put(self, uuid): def put(self, uuid):
"""Update tag information.""" """Update tag information."""
@@ -80,6 +83,7 @@ class Tag(Resource):
@auth.check_token @auth.check_token
@validate_openapi_request('createTag')
# Only cares for {'title': 'xxxx'} # Only cares for {'title': 'xxxx'}
def post(self): def post(self):
"""Create a single tag/group.""" """Create a single tag/group."""
@@ -100,6 +104,7 @@ class Tags(Resource):
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
@auth.check_token @auth.check_token
@validate_openapi_request('listTags')
def get(self): def get(self):
"""List tags/groups.""" """List tags/groups."""
result = {} result = {}

View File

@@ -11,7 +11,7 @@ from . import auth
import copy import copy
# Import schemas from __init__.py # Import schemas from __init__.py
from . import schema, schema_create_watch, schema_update_watch from . import schema, schema_create_watch, schema_update_watch, validate_openapi_request
class Watch(Resource): class Watch(Resource):
@@ -25,6 +25,7 @@ class Watch(Resource):
# @todo - version2 - ?muted and ?paused should be able to be called together, return the watch struct not "OK" # @todo - version2 - ?muted and ?paused should be able to be called together, return the watch struct not "OK"
# ?recheck=true # ?recheck=true
@auth.check_token @auth.check_token
@validate_openapi_request('getWatch')
def get(self, uuid): def get(self, uuid):
"""Get information about a single watch, recheck, pause, or mute.""" """Get information about a single watch, recheck, pause, or mute."""
from copy import deepcopy from copy import deepcopy
@@ -57,6 +58,7 @@ class Watch(Resource):
return watch return watch
@auth.check_token @auth.check_token
@validate_openapi_request('deleteWatch')
def delete(self, uuid): def delete(self, uuid):
"""Delete a watch and related history.""" """Delete a watch and related history."""
if not self.datastore.data['watching'].get(uuid): if not self.datastore.data['watching'].get(uuid):
@@ -66,6 +68,7 @@ class Watch(Resource):
return 'OK', 204 return 'OK', 204
@auth.check_token @auth.check_token
@validate_openapi_request('updateWatch')
@expects_json(schema_update_watch) @expects_json(schema_update_watch)
def put(self, uuid): def put(self, uuid):
"""Update watch information.""" """Update watch information."""
@@ -91,6 +94,7 @@ class WatchHistory(Resource):
# Get a list of available history for a watch by UUID # Get a list of available history for a watch by UUID
# curl http://localhost:5000/api/v1/watch/<string:uuid>/history # curl http://localhost:5000/api/v1/watch/<string:uuid>/history
@auth.check_token @auth.check_token
@validate_openapi_request('getWatchHistory')
def get(self, uuid): def get(self, uuid):
"""Get a list of all historical snapshots available for a watch.""" """Get a list of all historical snapshots available for a watch."""
watch = self.datastore.data['watching'].get(uuid) watch = self.datastore.data['watching'].get(uuid)
@@ -105,6 +109,7 @@ class WatchSingleHistory(Resource):
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
@auth.check_token @auth.check_token
@validate_openapi_request('getWatchSnapshot')
def get(self, uuid, timestamp): def get(self, uuid, timestamp):
"""Get single snapshot from watch.""" """Get single snapshot from watch."""
watch = self.datastore.data['watching'].get(uuid) watch = self.datastore.data['watching'].get(uuid)
@@ -138,6 +143,7 @@ class WatchFavicon(Resource):
self.datastore = kwargs['datastore'] self.datastore = kwargs['datastore']
@auth.check_token @auth.check_token
@validate_openapi_request('getWatchFavicon')
def get(self, uuid): def get(self, uuid):
"""Get favicon for a watch.""" """Get favicon for a watch."""
watch = self.datastore.data['watching'].get(uuid) watch = self.datastore.data['watching'].get(uuid)
@@ -172,6 +178,7 @@ class CreateWatch(Resource):
self.update_q = kwargs['update_q'] self.update_q = kwargs['update_q']
@auth.check_token @auth.check_token
@validate_openapi_request('createWatch')
@expects_json(schema_create_watch) @expects_json(schema_create_watch)
def post(self): def post(self):
"""Create a single watch.""" """Create a single watch."""
@@ -207,6 +214,7 @@ class CreateWatch(Resource):
return "Invalid or unsupported URL", 400 return "Invalid or unsupported URL", 400
@auth.check_token @auth.check_token
@validate_openapi_request('listWatches')
def get(self): def get(self):
"""List watches.""" """List watches."""
list = {} list = {}

View File

@@ -1,4 +1,9 @@
import copy import copy
import yaml
import functools
from flask import request, abort
from openapi_core import OpenAPI
from openapi_core.contrib.flask import FlaskOpenAPIRequest
from . import api_schema from . import api_schema
from ..model import watch_base from ..model import watch_base
@@ -25,6 +30,38 @@ schema_create_notification_urls['required'] = ['notification_urls']
schema_delete_notification_urls = copy.deepcopy(schema_notification_urls) schema_delete_notification_urls = copy.deepcopy(schema_notification_urls)
schema_delete_notification_urls['required'] = ['notification_urls'] schema_delete_notification_urls['required'] = ['notification_urls']
# Load OpenAPI spec for validation
_openapi_spec = None
def get_openapi_spec():
global _openapi_spec
if _openapi_spec is None:
import os
spec_path = os.path.join(os.path.dirname(__file__), '../../docs/api-spec.yaml')
with open(spec_path, 'r') as f:
spec_dict = yaml.safe_load(f)
_openapi_spec = OpenAPI.from_dict(spec_dict)
return _openapi_spec
def validate_openapi_request(operation_id):
"""Decorator to validate incoming requests against OpenAPI spec."""
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
try:
spec = get_openapi_spec()
openapi_request = FlaskOpenAPIRequest(request)
result = spec.unmarshal_request(openapi_request, operation_id)
if result.errors:
abort(400, message=f"OpenAPI validation failed: {result.errors}")
return f(*args, **kwargs)
except Exception as e:
# If OpenAPI validation fails, log but don't break existing functionality
print(f"OpenAPI validation warning for {operation_id}: {e}")
return f(*args, **kwargs)
return wrapper
return decorator
# Import all API resources # Import all API resources
from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch, WatchFavicon from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch, WatchFavicon
from .Tags import Tags, Tag from .Tags import Tags, Tag

View File

@@ -328,6 +328,7 @@ components:
paths: paths:
/watch: /watch:
get: get:
operationId: listWatches
tags: [Watch Management] tags: [Watch Management]
summary: List all watches summary: List all watches
description: Return concise list of available web page change monitors (watches) and basic info description: Return concise list of available web page change monitors (watches) and basic info
@@ -390,6 +391,7 @@ paths:
last_checked: 1640998800 last_checked: 1640998800
last_changed: 1640995200 last_changed: 1640995200
post: post:
operationId: createWatch
tags: [Watch Management] tags: [Watch Management]
summary: Create a new watch summary: Create a new watch
description: Create a single web page change monitor (watch). Requires at least 'url' to be set. description: Create a single web page change monitor (watch). Requires at least 'url' to be set.
@@ -453,7 +455,7 @@ paths:
/watch/{uuid}: /watch/{uuid}:
get: get:
operationId: getSingleWatch operationId: getWatch
tags: [Watch Management] tags: [Watch Management]
summary: Get single watch summary: Get single watch
description: Retrieve web page change monitor (watch) information and set muted/paused status. Returns the FULL Watch JSON. description: Retrieve web page change monitor (watch) information and set muted/paused status. Returns the FULL Watch JSON.
@@ -518,7 +520,7 @@ paths:
operationId: updateWatch operationId: updateWatch
tags: [Watch Management] tags: [Watch Management]
summary: Update watch summary: Update watch
description: Update an existing web page change monitor (watch) using JSON. Accepts the same structure as returned in [get single watch information](#operation/getSingleWatch). description: Update an existing web page change monitor (watch) using JSON. Accepts the same structure as returned in [get single watch information](#operation/getWatch).
x-code-samples: x-code-samples:
- lang: 'curl' - lang: 'curl'
source: | source: |
@@ -573,6 +575,7 @@ paths:
description: Server error description: Server error
delete: delete:
operationId: deleteWatch
tags: [Watch Management] tags: [Watch Management]
summary: Delete watch summary: Delete watch
description: Delete a web page change monitor (watch) and all related history description: Delete a web page change monitor (watch) and all related history
@@ -608,6 +611,7 @@ paths:
/watch/{uuid}/history: /watch/{uuid}/history:
get: get:
operationId: getWatchHistory
tags: [Watch History] tags: [Watch History]
summary: Get watch history summary: Get watch history
description: Get a list of all historical snapshots available for a web page change monitor (watch) description: Get a list of all historical snapshots available for a web page change monitor (watch)
@@ -647,6 +651,7 @@ paths:
/watch/{uuid}/history/{timestamp}: /watch/{uuid}/history/{timestamp}:
get: get:
operationId: getWatchSnapshot
tags: [Snapshots] tags: [Snapshots]
summary: Get single snapshot summary: Get single snapshot
description: Get single snapshot from web page change monitor (watch). Use 'latest' for the most recent snapshot. description: Get single snapshot from web page change monitor (watch). Use 'latest' for the most recent snapshot.
@@ -699,6 +704,7 @@ paths:
/watch/{uuid}/favicon: /watch/{uuid}/favicon:
get: get:
operationId: getWatchFavicon
tags: [Favicon] tags: [Favicon]
summary: Get watch favicon summary: Get watch favicon
description: Get the favicon for a web page change monitor (watch) as displayed in the watch overview list. description: Get the favicon for a web page change monitor (watch) as displayed in the watch overview list.
@@ -738,6 +744,7 @@ paths:
/tags: /tags:
get: get:
operationId: listTags
tags: [Group / Tag Management] tags: [Group / Tag Management]
summary: List all tags summary: List all tags
description: Return list of available tags/groups description: Return list of available tags/groups
@@ -774,8 +781,59 @@ paths:
notification_urls: ["discord://webhook_id/webhook_token"] notification_urls: ["discord://webhook_id/webhook_token"]
notification_muted: false notification_muted: false
/tag:
post:
operationId: createTag
tags: [Group / Tag Management]
summary: Create tag
description: Create a single tag/group
x-code-samples:
- lang: 'curl'
source: |
curl -X POST "http://localhost:5000/api/v1/tag" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Important Sites"
}'
- lang: 'Python'
source: |
import requests
headers = {
'x-api-key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
}
data = {'title': 'Important Sites'}
response = requests.post('http://localhost:5000/api/v1/tag',
headers=headers, json=data)
print(response.json())
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Tag'
example:
title: "Important Sites"
responses:
'201':
description: Tag created successfully
content:
application/json:
schema:
type: object
properties:
uuid:
type: string
format: uuid
description: UUID of the created tag
'400':
description: Invalid or unsupported tag
/tag/{uuid}: /tag/{uuid}:
get: get:
operationId: getTag
tags: [Group / Tag Management] tags: [Group / Tag Management]
summary: Get single tag summary: Get single tag
description: Retrieve tag information, set notification_muted status, recheck all web page change monitors (watches) in tag. description: Retrieve tag information, set notification_muted status, recheck all web page change monitors (watches) in tag.
@@ -827,6 +885,7 @@ paths:
description: Tag not found description: Tag not found
put: put:
operationId: updateTag
tags: [Group / Tag Management] tags: [Group / Tag Management]
summary: Update tag summary: Update tag
description: Update an existing tag using JSON description: Update an existing tag using JSON
@@ -877,6 +936,7 @@ paths:
description: Server error description: Server error
delete: delete:
operationId: deleteTag
tags: [Group / Tag Management] tags: [Group / Tag Management]
summary: Delete tag summary: Delete tag
description: Delete a tag/group and remove it from all web page change monitors (watches) description: Delete a tag/group and remove it from all web page change monitors (watches)
@@ -905,48 +965,10 @@ paths:
'200': '200':
description: Tag deleted successfully description: Tag deleted successfully
post:
tags: [Group / Tag Management]
summary: Create tag
description: Create a single tag/group
x-code-samples:
- lang: 'curl'
source: |
curl -X POST "http://localhost:5000/api/v1/tag/550e8400-e29b-41d4-a716-446655440000" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Important Sites"
}'
- lang: 'Python'
source: |
import requests
headers = {
'x-api-key': 'YOUR_API_KEY',
'Content-Type': 'application/json'
}
tag_uuid = '550e8400-e29b-41d4-a716-446655440000'
data = {'title': 'Important Sites'}
response = requests.post(f'http://localhost:5000/api/v1/tag/{tag_uuid}',
headers=headers, json=data)
print(response.text)
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Tag'
example:
title: "Important Sites"
responses:
'200':
description: Tag created successfully
'500':
description: Server error
/notifications: /notifications:
get: get:
operationId: getNotifications
tags: [Notifications] tags: [Notifications]
summary: Get notification URLs summary: Get notification URLs
description: Return the notification URL list from the configuration description: Return the notification URL list from the configuration
@@ -971,6 +993,7 @@ paths:
$ref: '#/components/schemas/NotificationUrls' $ref: '#/components/schemas/NotificationUrls'
post: post:
operationId: addNotifications
tags: [Notifications] tags: [Notifications]
summary: Add notification URLs summary: Add notification URLs
description: Add one or more notification URLs to the configuration description: Add one or more notification URLs to the configuration
@@ -1024,6 +1047,7 @@ paths:
description: Invalid input description: Invalid input
put: put:
operationId: replaceNotifications
tags: [Notifications] tags: [Notifications]
summary: Replace notification URLs summary: Replace notification URLs
description: Replace all notification URLs with the provided list (can be empty) description: Replace all notification URLs with the provided list (can be empty)
@@ -1071,6 +1095,7 @@ paths:
description: Invalid input description: Invalid input
delete: delete:
operationId: deleteNotifications
tags: [Notifications] tags: [Notifications]
summary: Delete notification URLs summary: Delete notification URLs
description: Delete one or more notification URLs from the configuration description: Delete one or more notification URLs from the configuration
@@ -1115,6 +1140,7 @@ paths:
/search: /search:
get: get:
operationId: searchWatches
tags: [Search] tags: [Search]
summary: Search watches summary: Search watches
description: Search web page change monitors (watches) by URL or title text description: Search web page change monitors (watches) by URL or title text
@@ -1169,6 +1195,7 @@ paths:
/import: /import:
post: post:
operationId: importWatches
tags: [Import] tags: [Import]
summary: Import watch URLs summary: Import watch URLs
description: Import a list of URLs to monitor. Accepts line-separated URLs in request body. description: Import a list of URLs to monitor. Accepts line-separated URLs in request body.
@@ -1239,6 +1266,7 @@ paths:
/systeminfo: /systeminfo:
get: get:
operationId: getSystemInfo
tags: [System Information] tags: [System Information]
summary: Get system information summary: Get system information
description: Return information about the current system state description: Return information about the current system state

File diff suppressed because one or more lines are too long

View File

@@ -89,6 +89,9 @@ pytest-flask ~=1.2
# Anything 4.0 and up but not 5.0 # Anything 4.0 and up but not 5.0
jsonschema ~= 4.0 jsonschema ~= 4.0
# OpenAPI validation support
openapi-core[flask] >= 0.19.0
loguru loguru