mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-10-30 22:27:52 +00:00 
			
		
		
		
	Compare commits
	
		
			3 Commits
		
	
	
		
			0.50.29
			...
			OpenAPI-va
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 3acf9fa60d | ||
|   | 001d294654 | ||
|   | 994d17fc7a | 
							
								
								
									
										4
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/test-only.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user