Compare commits

..

3 Commits

Author SHA1 Message Date
dgtlmoon
a0ab1ab6be Update README.md 2025-08-23 23:56:35 +02:00
dgtlmoon
f4f716fffa More openAPI fixes 2025-08-23 23:53:06 +02:00
dgtlmoon
6e6136aaa7 Lets go openapi spec 2025-08-23 23:32:53 +02:00
12 changed files with 190 additions and 284 deletions

View File

@@ -15,10 +15,6 @@ 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

View File

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

View File

@@ -1,7 +1,9 @@
from flask_expects_json import expects_json
from flask_restful import Resource, abort
from flask_restful import Resource
from . import auth
from flask_restful import abort, Resource
from flask import request
from . import auth, validate_openapi_request
from . import auth
from . import schema_create_notification_urls, schema_delete_notification_urls
class Notifications(Resource):
@@ -10,7 +12,6 @@ class Notifications(Resource):
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('getNotifications')
def get(self):
"""Return Notification URL List."""
@@ -21,7 +22,6 @@ class Notifications(Resource):
}, 200
@auth.check_token
@validate_openapi_request('addNotifications')
@expects_json(schema_create_notification_urls)
def post(self):
"""Create Notification URLs."""
@@ -49,7 +49,6 @@ 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."""
@@ -72,7 +71,6 @@ 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."""

View File

@@ -1,6 +1,6 @@
from flask_restful import Resource, abort
from flask import request
from . import auth, validate_openapi_request
from . import auth
class Search(Resource):
def __init__(self, **kwargs):
@@ -8,7 +8,6 @@ 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()

View File

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

View File

@@ -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, validate_openapi_request
from . import schema_tag, schema_create_tag, schema_update_tag
class Tag(Resource):
@@ -19,7 +19,6 @@ 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
@@ -51,7 +50,6 @@ 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):
@@ -68,7 +66,6 @@ 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."""
@@ -83,7 +80,6 @@ class Tag(Resource):
@auth.check_token
@validate_openapi_request('createTag')
# Only cares for {'title': 'xxxx'}
def post(self):
"""Create a single tag/group."""
@@ -104,7 +100,6 @@ class Tags(Resource):
self.datastore = kwargs['datastore']
@auth.check_token
@validate_openapi_request('listTags')
def get(self):
"""List tags/groups."""
result = {}

View File

@@ -11,7 +11,7 @@ from . import auth
import copy
# Import schemas from __init__.py
from . import schema, schema_create_watch, schema_update_watch, validate_openapi_request
from . import schema, schema_create_watch, schema_update_watch
class Watch(Resource):
@@ -25,7 +25,6 @@ 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
@@ -58,7 +57,6 @@ 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):
@@ -68,7 +66,6 @@ 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."""
@@ -94,7 +91,6 @@ 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)
@@ -109,7 +105,6 @@ 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)
@@ -143,7 +138,6 @@ 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)
@@ -178,7 +172,6 @@ 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."""
@@ -214,7 +207,6 @@ class CreateWatch(Resource):
return "Invalid or unsupported URL", 400
@auth.check_token
@validate_openapi_request('listWatches')
def get(self):
"""List watches."""
list = {}

View File

@@ -1,9 +1,4 @@
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
@@ -30,38 +25,6 @@ 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

View File

@@ -203,7 +203,7 @@ nav
<div class="tab-pane-inner" id="api">
<h4>API Access</h4>
<p>Drive your changedetection.io via API, More about <a href="https://changedetection.io/docs/api_v1/index.html">API access and examples here</a>.</p>
<p>Drive your changedetection.io via API, More about <a href="https://github.com/dgtlmoon/changedetection.io/wiki/API-Reference">API access here</a></p>
<div class="pure-control-group">
{{ render_checkbox_field(form.application.form.api_access_token_enabled) }}

View File

@@ -28,7 +28,7 @@ info:
For example: `x-api-key: YOUR_API_KEY`
version: 0.1.0
version: 1.0.0
contact:
name: ChangeDetection.io
url: https://github.com/dgtlmoon/changedetection.io
@@ -89,8 +89,6 @@ tags:
Configure global notification endpoints that can be used across all your watches. Supports various
notification services including email, Discord, Slack, webhooks, and many other popular platforms.
These settings serve as defaults that can be overridden at the individual watch or tag level.
The notification syntax uses [https://github.com/caronc/apprise](https://github.com/caronc/apprise).
- name: Search
description: |
@@ -125,7 +123,7 @@ components:
uuid:
type: string
format: uuid
description: Unique identifier for the web page change monitor (watch)
description: Unique identifier for the watch
readOnly: true
url:
type: string
@@ -134,11 +132,11 @@ components:
maxLength: 5000
title:
type: string
description: Custom title for the web page change monitor (watch)
description: Custom title for the watch
maxLength: 5000
tag:
type: string
description: Tag UUID to associate with this web page change monitor (watch)
description: Tag UUID to associate with this watch
maxLength: 5000
tags:
type: array
@@ -147,7 +145,7 @@ components:
description: Array of tag UUIDs
paused:
type: boolean
description: Whether the web page change monitor (watch) is paused
description: Whether the watch is paused
muted:
type: boolean
description: Whether notifications are muted
@@ -197,7 +195,7 @@ components:
type: array
items:
type: string
description: Notification URLs for this web page change monitor (watch)
description: Notification URLs for this watch
notification_title:
type: string
description: Custom notification title
@@ -267,7 +265,7 @@ components:
type: array
items:
type: string
description: Default notification URLs for web page change monitors (watches) with this tag
description: Default notification URLs for watches with this tag
notification_muted:
type: boolean
description: Whether notifications are muted for this tag
@@ -291,7 +289,7 @@ components:
properties:
watch_count:
type: integer
description: Total number of web page change monitors (watches)
description: Total number of watches
tag_count:
type: integer
description: Total number of tags
@@ -309,7 +307,7 @@ components:
type: object
additionalProperties:
$ref: '#/components/schemas/Watch'
description: Dictionary of matching web page change monitors (watches) keyed by UUID
description: Dictionary of matching watches keyed by UUID
WatchHistory:
type: object
@@ -328,10 +326,9 @@ 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
description: Return concise list of available watches and basic info
x-code-samples:
- lang: 'curl'
source: |
@@ -391,10 +388,9 @@ 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.
description: Create a single watch. Requires at least 'url' to be set.
x-code-samples:
- lang: 'curl'
source: |
@@ -440,7 +436,7 @@ paths:
hours: 1
responses:
'200':
description: Web page change monitor (watch) created successfully
description: Watch created successfully
content:
text/plain:
schema:
@@ -455,10 +451,10 @@ paths:
/watch/{uuid}:
get:
operationId: getWatch
operationId: getSingleWatch
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.
description: Retrieve watch information and set muted/paused status. Returns the FULL Watch JSON.
x-code-samples:
- lang: 'curl'
source: |
@@ -476,13 +472,13 @@ paths:
- name: uuid
in: path
required: true
description: Web page change monitor (watch) unique ID
description: Watch unique ID
schema:
type: string
format: uuid
- name: recheck
in: query
description: Recheck this web page change monitor (watch)
description: Recheck this watch
schema:
type: string
enum: ["1", "true"]
@@ -510,7 +506,7 @@ paths:
type: string
example: "OK"
'404':
description: Web page change monitor (watch) not found
description: Watch not found
content:
application/json:
schema:
@@ -520,7 +516,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/getWatch).
description: Update an existing watch using JSON. Accepts the same structure as returned in [get single watch information](#operation/getSingleWatch).
x-code-samples:
- lang: 'curl'
source: |
@@ -553,7 +549,7 @@ paths:
- name: uuid
in: path
required: true
description: Web page change monitor (watch) unique ID
description: Watch unique ID
schema:
type: string
format: uuid
@@ -565,7 +561,7 @@ paths:
$ref: '#/components/schemas/Watch'
responses:
'200':
description: Web page change monitor (watch) updated successfully
description: Watch updated successfully
content:
text/plain:
schema:
@@ -575,10 +571,9 @@ 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
description: Delete a watch and all related history
x-code-samples:
- lang: 'curl'
source: |
@@ -596,13 +591,13 @@ paths:
- name: uuid
in: path
required: true
description: Web page change monitor (watch) unique ID
description: Watch unique ID
schema:
type: string
format: uuid
responses:
'200':
description: Web page change monitor (watch) deleted successfully
description: Watch deleted successfully
content:
text/plain:
schema:
@@ -611,10 +606,9 @@ 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)
description: Get a list of all historical snapshots available for a watch
x-code-samples:
- lang: 'curl'
source: |
@@ -632,7 +626,7 @@ paths:
- name: uuid
in: path
required: true
description: Web page change monitor (watch) unique ID
description: Watch unique ID
schema:
type: string
format: uuid
@@ -647,14 +641,13 @@ paths:
"1640995200": "/path/to/snapshot1.txt"
"1640998800": "/path/to/snapshot2.txt"
'404':
description: Web page change monitor (watch) not found
description: Watch not found
/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.
description: Get single snapshot from watch. Use 'latest' for the most recent snapshot.
x-code-samples:
- lang: 'curl'
source: |
@@ -673,7 +666,7 @@ paths:
- name: uuid
in: path
required: true
description: Web page change monitor (watch) unique ID
description: Watch unique ID
schema:
type: string
format: uuid
@@ -704,10 +697,9 @@ 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.
description: Get the favicon for a watch as displayed in the watch overview list.
x-code-samples:
- lang: 'curl'
source: |
@@ -727,7 +719,7 @@ paths:
- name: uuid
in: path
required: true
description: Web page change monitor (watch) unique ID
description: Watch unique ID
schema:
type: string
format: uuid
@@ -744,7 +736,6 @@ paths:
/tags:
get:
operationId: listTags
tags: [Group / Tag Management]
summary: List all tags
description: Return list of available tags/groups
@@ -781,62 +772,11 @@ 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.
description: Retrieve tag information, set notification_muted status, recheck all in tag.
x-code-samples:
- lang: 'curl'
source: |
@@ -866,7 +806,7 @@ paths:
enum: [muted, unmuted]
- name: recheck
in: query
description: Queue all web page change monitors (watches) with this tag for recheck
description: Queue all watches with this tag for recheck
schema:
type: string
enum: ["true"]
@@ -885,7 +825,6 @@ paths:
description: Tag not found
put:
operationId: updateTag
tags: [Group / Tag Management]
summary: Update tag
description: Update an existing tag using JSON
@@ -936,10 +875,9 @@ 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)
description: Delete a tag/group and remove it from all watches
x-code-samples:
- lang: 'curl'
source: |
@@ -965,10 +903,48 @@ 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
@@ -993,7 +969,6 @@ paths:
$ref: '#/components/schemas/NotificationUrls'
post:
operationId: addNotifications
tags: [Notifications]
summary: Add notification URLs
description: Add one or more notification URLs to the configuration
@@ -1047,7 +1022,6 @@ 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)
@@ -1095,7 +1069,6 @@ paths:
description: Invalid input
delete:
operationId: deleteNotifications
tags: [Notifications]
summary: Delete notification URLs
description: Delete one or more notification URLs from the configuration
@@ -1140,10 +1113,9 @@ paths:
/search:
get:
operationId: searchWatches
tags: [Search]
summary: Search watches
description: Search web page change monitors (watches) by URL or title text
description: Search watches by URL or title text
x-code-samples:
- lang: 'curl'
source: |
@@ -1195,7 +1167,6 @@ 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.
@@ -1221,17 +1192,17 @@ paths:
parameters:
- name: tag_uuids
in: query
description: Tag UUID to apply to imported web page change monitors (watches)
description: Tag UUID to apply to imported watches
schema:
type: string
- name: tag
in: query
description: Tag name to apply to imported web page change monitors (watches)
description: Tag name to apply to imported watches
schema:
type: string
- name: proxy
in: query
description: Proxy key to use for imported web page change monitors (watches)
description: Proxy key to use for imported watches
schema:
type: string
- name: dedupe
@@ -1266,7 +1237,6 @@ 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

View File

@@ -89,9 +89,6 @@ 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