kopia lustrzana https://github.com/dgtlmoon/changedetection.io
API - API endpoint call validation against OpenAPI specification YML also (#3386)
rodzic
8b8f280565
commit
a177d02406
|
@ -15,6 +15,10 @@ jobs:
|
|||
ruff check . --select E9,F63,F7,F82
|
||||
# Complete check with errors treated as warnings
|
||||
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:
|
||||
needs: lint-code
|
||||
|
|
|
@ -3,7 +3,7 @@ from changedetectionio.strtobool import strtobool
|
|||
from flask_restful import abort, Resource
|
||||
from flask import request
|
||||
import validators
|
||||
from . import auth
|
||||
from . import auth, validate_openapi_request
|
||||
|
||||
|
||||
class Import(Resource):
|
||||
|
@ -12,6 +12,7 @@ class Import(Resource):
|
|||
self.datastore = kwargs['datastore']
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('importWatches')
|
||||
def post(self):
|
||||
"""Import a list of watched URLs."""
|
||||
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
from flask_expects_json import expects_json
|
||||
from flask_restful import Resource
|
||||
from . import auth
|
||||
from flask_restful import abort, Resource
|
||||
from flask_restful import Resource, abort
|
||||
from flask import request
|
||||
from . import auth
|
||||
from . import auth, validate_openapi_request
|
||||
from . import schema_create_notification_urls, schema_delete_notification_urls
|
||||
|
||||
class Notifications(Resource):
|
||||
|
@ -12,6 +10,7 @@ class Notifications(Resource):
|
|||
self.datastore = kwargs['datastore']
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('getNotifications')
|
||||
def get(self):
|
||||
"""Return Notification URL List."""
|
||||
|
||||
|
@ -22,6 +21,7 @@ class Notifications(Resource):
|
|||
}, 200
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('addNotifications')
|
||||
@expects_json(schema_create_notification_urls)
|
||||
def post(self):
|
||||
"""Create Notification URLs."""
|
||||
|
@ -49,6 +49,7 @@ class Notifications(Resource):
|
|||
return {'notification_urls': added_urls}, 201
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('replaceNotifications')
|
||||
@expects_json(schema_create_notification_urls)
|
||||
def put(self):
|
||||
"""Replace Notification URLs."""
|
||||
|
@ -71,6 +72,7 @@ class Notifications(Resource):
|
|||
return {'notification_urls': clean_urls}, 200
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('deleteNotifications')
|
||||
@expects_json(schema_delete_notification_urls)
|
||||
def delete(self):
|
||||
"""Delete Notification URLs."""
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from flask_restful import Resource, abort
|
||||
from flask import request
|
||||
from . import auth
|
||||
from . import auth, validate_openapi_request
|
||||
|
||||
class Search(Resource):
|
||||
def __init__(self, **kwargs):
|
||||
|
@ -8,6 +8,7 @@ class Search(Resource):
|
|||
self.datastore = kwargs['datastore']
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('searchWatches')
|
||||
def get(self):
|
||||
"""Search for watches by URL or title text."""
|
||||
query = request.args.get('q', '').strip()
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
from flask_restful import Resource
|
||||
from . import auth
|
||||
from . import auth, validate_openapi_request
|
||||
|
||||
|
||||
class SystemInfo(Resource):
|
||||
|
@ -9,6 +9,7 @@ class SystemInfo(Resource):
|
|||
self.update_q = kwargs['update_q']
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('getSystemInfo')
|
||||
def get(self):
|
||||
"""Return system info."""
|
||||
import time
|
||||
|
|
|
@ -7,7 +7,7 @@ from flask import request
|
|||
from . import auth
|
||||
|
||||
# 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):
|
||||
|
@ -19,6 +19,7 @@ class Tag(Resource):
|
|||
# Get information about a single tag
|
||||
# curl http://localhost:5000/api/v1/tag/<string:uuid>
|
||||
@auth.check_token
|
||||
@validate_openapi_request('getTag')
|
||||
def get(self, uuid):
|
||||
"""Get data for a single tag/group, toggle notification muting, or recheck all."""
|
||||
from copy import deepcopy
|
||||
|
@ -50,6 +51,7 @@ class Tag(Resource):
|
|||
return tag
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('deleteTag')
|
||||
def delete(self, uuid):
|
||||
"""Delete a tag/group and remove it from all watches."""
|
||||
if not self.datastore.data['settings']['application']['tags'].get(uuid):
|
||||
|
@ -66,6 +68,7 @@ class Tag(Resource):
|
|||
return 'OK', 204
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('updateTag')
|
||||
@expects_json(schema_update_tag)
|
||||
def put(self, uuid):
|
||||
"""Update tag information."""
|
||||
|
@ -80,6 +83,7 @@ class Tag(Resource):
|
|||
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('createTag')
|
||||
# Only cares for {'title': 'xxxx'}
|
||||
def post(self):
|
||||
"""Create a single tag/group."""
|
||||
|
@ -100,6 +104,7 @@ class Tags(Resource):
|
|||
self.datastore = kwargs['datastore']
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('listTags')
|
||||
def get(self):
|
||||
"""List tags/groups."""
|
||||
result = {}
|
||||
|
|
|
@ -11,7 +11,7 @@ from . import auth
|
|||
import copy
|
||||
|
||||
# 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):
|
||||
|
@ -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"
|
||||
# ?recheck=true
|
||||
@auth.check_token
|
||||
@validate_openapi_request('getWatch')
|
||||
def get(self, uuid):
|
||||
"""Get information about a single watch, recheck, pause, or mute."""
|
||||
from copy import deepcopy
|
||||
|
@ -57,6 +58,7 @@ class Watch(Resource):
|
|||
return watch
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('deleteWatch')
|
||||
def delete(self, uuid):
|
||||
"""Delete a watch and related history."""
|
||||
if not self.datastore.data['watching'].get(uuid):
|
||||
|
@ -66,6 +68,7 @@ class Watch(Resource):
|
|||
return 'OK', 204
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('updateWatch')
|
||||
@expects_json(schema_update_watch)
|
||||
def put(self, uuid):
|
||||
"""Update watch information."""
|
||||
|
@ -91,6 +94,7 @@ class WatchHistory(Resource):
|
|||
# Get a list of available history for a watch by UUID
|
||||
# curl http://localhost:5000/api/v1/watch/<string:uuid>/history
|
||||
@auth.check_token
|
||||
@validate_openapi_request('getWatchHistory')
|
||||
def get(self, uuid):
|
||||
"""Get a list of all historical snapshots available for a watch."""
|
||||
watch = self.datastore.data['watching'].get(uuid)
|
||||
|
@ -105,6 +109,7 @@ class WatchSingleHistory(Resource):
|
|||
self.datastore = kwargs['datastore']
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('getWatchSnapshot')
|
||||
def get(self, uuid, timestamp):
|
||||
"""Get single snapshot from watch."""
|
||||
watch = self.datastore.data['watching'].get(uuid)
|
||||
|
@ -138,6 +143,7 @@ class WatchFavicon(Resource):
|
|||
self.datastore = kwargs['datastore']
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('getWatchFavicon')
|
||||
def get(self, uuid):
|
||||
"""Get favicon for a watch."""
|
||||
watch = self.datastore.data['watching'].get(uuid)
|
||||
|
@ -172,6 +178,7 @@ class CreateWatch(Resource):
|
|||
self.update_q = kwargs['update_q']
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('createWatch')
|
||||
@expects_json(schema_create_watch)
|
||||
def post(self):
|
||||
"""Create a single watch."""
|
||||
|
@ -207,6 +214,7 @@ class CreateWatch(Resource):
|
|||
return "Invalid or unsupported URL", 400
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('listWatches')
|
||||
def get(self):
|
||||
"""List watches."""
|
||||
list = {}
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
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 ..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['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
|
||||
from .Watch import Watch, WatchHistory, WatchSingleHistory, CreateWatch, WatchFavicon
|
||||
from .Tags import Tags, Tag
|
||||
|
|
|
@ -328,6 +328,7 @@ components:
|
|||
paths:
|
||||
/watch:
|
||||
get:
|
||||
operationId: listWatches
|
||||
tags: [Watch Management]
|
||||
summary: List all watches
|
||||
description: Return concise list of available web page change monitors (watches) and basic info
|
||||
|
@ -390,6 +391,7 @@ paths:
|
|||
last_checked: 1640998800
|
||||
last_changed: 1640995200
|
||||
post:
|
||||
operationId: createWatch
|
||||
tags: [Watch Management]
|
||||
summary: Create a new watch
|
||||
description: Create a single web page change monitor (watch). Requires at least 'url' to be set.
|
||||
|
@ -453,7 +455,7 @@ paths:
|
|||
|
||||
/watch/{uuid}:
|
||||
get:
|
||||
operationId: getSingleWatch
|
||||
operationId: getWatch
|
||||
tags: [Watch Management]
|
||||
summary: Get single watch
|
||||
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
|
||||
tags: [Watch Management]
|
||||
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:
|
||||
- lang: 'curl'
|
||||
source: |
|
||||
|
@ -573,6 +575,7 @@ paths:
|
|||
description: Server error
|
||||
|
||||
delete:
|
||||
operationId: deleteWatch
|
||||
tags: [Watch Management]
|
||||
summary: Delete watch
|
||||
description: Delete a web page change monitor (watch) and all related history
|
||||
|
@ -608,6 +611,7 @@ paths:
|
|||
|
||||
/watch/{uuid}/history:
|
||||
get:
|
||||
operationId: getWatchHistory
|
||||
tags: [Watch History]
|
||||
summary: Get watch history
|
||||
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}:
|
||||
get:
|
||||
operationId: getWatchSnapshot
|
||||
tags: [Snapshots]
|
||||
summary: Get single 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:
|
||||
get:
|
||||
operationId: getWatchFavicon
|
||||
tags: [Favicon]
|
||||
summary: Get watch favicon
|
||||
description: Get the favicon for a web page change monitor (watch) as displayed in the watch overview list.
|
||||
|
@ -738,6 +744,7 @@ paths:
|
|||
|
||||
/tags:
|
||||
get:
|
||||
operationId: listTags
|
||||
tags: [Group / Tag Management]
|
||||
summary: List all tags
|
||||
description: Return list of available tags/groups
|
||||
|
@ -774,8 +781,59 @@ paths:
|
|||
notification_urls: ["discord://webhook_id/webhook_token"]
|
||||
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}:
|
||||
get:
|
||||
operationId: getTag
|
||||
tags: [Group / Tag Management]
|
||||
summary: Get single 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
|
||||
|
||||
put:
|
||||
operationId: updateTag
|
||||
tags: [Group / Tag Management]
|
||||
summary: Update tag
|
||||
description: Update an existing tag using JSON
|
||||
|
@ -877,6 +936,7 @@ paths:
|
|||
description: Server error
|
||||
|
||||
delete:
|
||||
operationId: deleteTag
|
||||
tags: [Group / Tag Management]
|
||||
summary: Delete tag
|
||||
description: Delete a tag/group and remove it from all web page change monitors (watches)
|
||||
|
@ -905,48 +965,10 @@ paths:
|
|||
'200':
|
||||
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:
|
||||
get:
|
||||
operationId: getNotifications
|
||||
tags: [Notifications]
|
||||
summary: Get notification URLs
|
||||
description: Return the notification URL list from the configuration
|
||||
|
@ -971,6 +993,7 @@ paths:
|
|||
$ref: '#/components/schemas/NotificationUrls'
|
||||
|
||||
post:
|
||||
operationId: addNotifications
|
||||
tags: [Notifications]
|
||||
summary: Add notification URLs
|
||||
description: Add one or more notification URLs to the configuration
|
||||
|
@ -1024,6 +1047,7 @@ paths:
|
|||
description: Invalid input
|
||||
|
||||
put:
|
||||
operationId: replaceNotifications
|
||||
tags: [Notifications]
|
||||
summary: Replace notification URLs
|
||||
description: Replace all notification URLs with the provided list (can be empty)
|
||||
|
@ -1071,6 +1095,7 @@ paths:
|
|||
description: Invalid input
|
||||
|
||||
delete:
|
||||
operationId: deleteNotifications
|
||||
tags: [Notifications]
|
||||
summary: Delete notification URLs
|
||||
description: Delete one or more notification URLs from the configuration
|
||||
|
@ -1115,6 +1140,7 @@ paths:
|
|||
|
||||
/search:
|
||||
get:
|
||||
operationId: searchWatches
|
||||
tags: [Search]
|
||||
summary: Search watches
|
||||
description: Search web page change monitors (watches) by URL or title text
|
||||
|
@ -1169,6 +1195,7 @@ paths:
|
|||
|
||||
/import:
|
||||
post:
|
||||
operationId: importWatches
|
||||
tags: [Import]
|
||||
summary: Import watch URLs
|
||||
description: Import a list of URLs to monitor. Accepts line-separated URLs in request body.
|
||||
|
@ -1239,6 +1266,7 @@ paths:
|
|||
|
||||
/systeminfo:
|
||||
get:
|
||||
operationId: getSystemInfo
|
||||
tags: [System Information]
|
||||
summary: Get system information
|
||||
description: Return information about the current system state
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -89,6 +89,9 @@ pytest-flask ~=1.2
|
|||
# Anything 4.0 and up but not 5.0
|
||||
jsonschema ~= 4.0
|
||||
|
||||
# OpenAPI validation support
|
||||
openapi-core[flask] >= 0.19.0
|
||||
|
||||
|
||||
loguru
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue