From acf9e4a1e60faddcab0e445bbd718d40848cffa4 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Fri, 13 Feb 2026 09:10:31 +0100 Subject: [PATCH] Remove flask_expects_json --- changedetectionio/api/Import.py | 29 +++-- changedetectionio/api/Notifications.py | 5 - changedetectionio/api/Tags.py | 5 +- changedetectionio/api/Watch.py | 6 +- changedetectionio/api/__init__.py | 69 ++++++----- changedetectionio/api/api_schema.py | 162 ------------------------- requirements.txt | 5 +- 7 files changed, 60 insertions(+), 221 deletions(-) delete mode 100644 changedetectionio/api/api_schema.py diff --git a/changedetectionio/api/Import.py b/changedetectionio/api/Import.py index 1c1fddb65..9b0cec4e4 100644 --- a/changedetectionio/api/Import.py +++ b/changedetectionio/api/Import.py @@ -2,7 +2,7 @@ from changedetectionio.strtobool import strtobool from flask_restful import abort, Resource from flask import request from functools import wraps -from . import auth, validate_openapi_request, schema_create_watch +from . import auth, validate_openapi_request from ..validate_url import is_safe_valid_url import json @@ -33,9 +33,25 @@ def convert_query_param_to_type(value, schema_property): Returns: Converted value in the appropriate type + + Supports both OpenAPI 3.1 formats: + - type: [string, 'null'] (array format) + - anyOf: [{type: string}, {type: null}] (anyOf format) """ - # Handle anyOf schemas (extract the first type) - if 'anyOf' in schema_property: + prop_type = schema_property.get('type') + + # Handle OpenAPI 3.1 type arrays: type: [string, 'null'] + if isinstance(prop_type, list): + # Use the first non-null type from the array + for t in prop_type: + if t != 'null': + prop_type = t + break + else: + prop_type = None + + # Handle anyOf schemas (older format) + elif 'anyOf' in schema_property: # Use the first non-null type from anyOf for option in schema_property['anyOf']: if option.get('type') and option.get('type') != 'null': @@ -43,8 +59,6 @@ def convert_query_param_to_type(value, schema_property): break else: prop_type = None - else: - prop_type = schema_property.get('type') # Handle array type (e.g., notification_urls) if prop_type == 'array': @@ -89,7 +103,7 @@ class Import(Resource): @validate_openapi_request('importWatches') def post(self): """Import a list of watched URLs with optional watch configuration.""" - + from . import get_watch_schema_properties # Special parameters that are NOT watch configuration special_params = {'tag', 'tag_uuids', 'dedupe', 'proxy'} @@ -115,7 +129,8 @@ class Import(Resource): tag_uuids = tag_uuids.split(',') # Extract ALL other query parameters as watch configuration - schema_properties = schema_create_watch.get('properties', {}) + # Get schema from OpenAPI spec (replaces old schema_create_watch) + schema_properties = get_watch_schema_properties() for param_name, param_value in request.args.items(): # Skip special parameters if param_name in special_params: diff --git a/changedetectionio/api/Notifications.py b/changedetectionio/api/Notifications.py index 7d2f8db98..8ce4e9774 100644 --- a/changedetectionio/api/Notifications.py +++ b/changedetectionio/api/Notifications.py @@ -1,8 +1,6 @@ -from flask_expects_json import expects_json from flask_restful import Resource, abort from flask import request from . import auth, validate_openapi_request -from . import schema_create_notification_urls, schema_delete_notification_urls class Notifications(Resource): def __init__(self, **kwargs): @@ -22,7 +20,6 @@ class Notifications(Resource): @auth.check_token @validate_openapi_request('addNotifications') - @expects_json(schema_create_notification_urls) def post(self): """Create Notification URLs.""" @@ -50,7 +47,6 @@ class Notifications(Resource): @auth.check_token @validate_openapi_request('replaceNotifications') - @expects_json(schema_create_notification_urls) def put(self): """Replace Notification URLs.""" json_data = request.get_json() @@ -73,7 +69,6 @@ class Notifications(Resource): @auth.check_token @validate_openapi_request('deleteNotifications') - @expects_json(schema_delete_notification_urls) def delete(self): """Delete Notification URLs.""" diff --git a/changedetectionio/api/Tags.py b/changedetectionio/api/Tags.py index 726b185ad..61a8c9587 100644 --- a/changedetectionio/api/Tags.py +++ b/changedetectionio/api/Tags.py @@ -1,6 +1,5 @@ from changedetectionio import queuedWatchMetaData from changedetectionio import worker_pool -from flask_expects_json import expects_json from flask_restful import abort, Resource from loguru import logger @@ -8,8 +7,7 @@ import threading 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 validate_openapi_request class Tag(Resource): @@ -102,7 +100,6 @@ class Tag(Resource): @auth.check_token @validate_openapi_request('updateTag') - @expects_json(schema_update_tag) def put(self, uuid): """Update tag information.""" tag = self.datastore.data['settings']['application']['tags'].get(uuid) diff --git a/changedetectionio/api/Watch.py b/changedetectionio/api/Watch.py index b13ffb174..2b563dda4 100644 --- a/changedetectionio/api/Watch.py +++ b/changedetectionio/api/Watch.py @@ -8,13 +8,11 @@ from . import auth from changedetectionio import queuedWatchMetaData, strtobool from changedetectionio import worker_pool from flask import request, make_response, send_from_directory -from flask_expects_json import expects_json from flask_restful import abort, Resource from loguru import logger import copy -# Import schemas from __init__.py -from . import schema, schema_create_watch, schema_update_watch, validate_openapi_request +from . import validate_openapi_request from ..notification import valid_notification_formats from ..notification.handler import newline_re @@ -121,7 +119,6 @@ class Watch(Resource): @auth.check_token @validate_openapi_request('updateWatch') - @expects_json(schema_update_watch) def put(self, uuid): """Update watch information.""" watch = self.datastore.data['watching'].get(uuid) @@ -393,7 +390,6 @@ class CreateWatch(Resource): @auth.check_token @validate_openapi_request('createWatch') - @expects_json(schema_create_watch) def post(self): """Create a single watch.""" diff --git a/changedetectionio/api/__init__.py b/changedetectionio/api/__init__.py index a73bbbc17..91ff745ad 100644 --- a/changedetectionio/api/__init__.py +++ b/changedetectionio/api/__init__.py @@ -1,41 +1,6 @@ -import copy import functools from flask import request, abort from loguru import logger -from . import api_schema -from ..model import watch_base - -# Build a JSON Schema atleast partially based on our Watch model -watch_base_config = watch_base() -schema = api_schema.build_watch_json_schema(watch_base_config) - -schema_create_watch = copy.deepcopy(schema) -schema_create_watch['required'] = ['url'] -del schema_create_watch['properties']['last_viewed'] -# Allow processor_config_* fields (handled separately in endpoint) -schema_create_watch['patternProperties'] = { - '^processor_config_': {'type': ['string', 'number', 'boolean', 'object', 'array', 'null']} -} - -schema_update_watch = copy.deepcopy(schema) -schema_update_watch['additionalProperties'] = False -# Allow processor_config_* fields (handled separately in endpoint) -schema_update_watch['patternProperties'] = { - '^processor_config_': {'type': ['string', 'number', 'boolean', 'object', 'array', 'null']} -} - -# Tag schema is also based on watch_base since Tag inherits from it -schema_tag = copy.deepcopy(schema) -schema_create_tag = copy.deepcopy(schema_tag) -schema_create_tag['required'] = ['title'] -schema_update_tag = copy.deepcopy(schema_tag) -schema_update_tag['additionalProperties'] = False - -schema_notification_urls = copy.deepcopy(schema) -schema_create_notification_urls = copy.deepcopy(schema_notification_urls) -schema_create_notification_urls['required'] = ['notification_urls'] -schema_delete_notification_urls = copy.deepcopy(schema_notification_urls) -schema_delete_notification_urls['required'] = ['notification_urls'] @functools.cache def get_openapi_spec(): @@ -54,6 +19,40 @@ def get_openapi_spec(): _openapi_spec = OpenAPI.from_dict(spec_dict) return _openapi_spec +@functools.cache +def get_openapi_schema_dict(): + """ + Get the raw OpenAPI spec dictionary for schema access. + + Used by Import endpoint to validate and convert query parameters. + Returns the YAML dict directly (not the OpenAPI object). + """ + import os + import yaml + + spec_path = os.path.join(os.path.dirname(__file__), '../../docs/api-spec.yaml') + if not os.path.exists(spec_path): + spec_path = os.path.join(os.path.dirname(__file__), '../docs/api-spec.yaml') + + with open(spec_path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) + +def get_watch_schema_properties(): + """ + Extract watch schema properties from OpenAPI spec for Import endpoint. + + Returns a dict of property names to their schema definitions, + suitable for validating query parameters. + """ + spec_dict = get_openapi_schema_dict() + + # Get CreateWatch schema (which references WatchBase via allOf) + create_watch_schema = spec_dict['components']['schemas']['CreateWatch'] + watch_base_schema = spec_dict['components']['schemas']['WatchBase'] + + # Return WatchBase properties (CreateWatch uses allOf to extend it) + return watch_base_schema.get('properties', {}) + def validate_openapi_request(operation_id): """Decorator to validate incoming requests against OpenAPI spec.""" def decorator(f): diff --git a/changedetectionio/api/api_schema.py b/changedetectionio/api/api_schema.py deleted file mode 100644 index cf6bc9445..000000000 --- a/changedetectionio/api/api_schema.py +++ /dev/null @@ -1,162 +0,0 @@ -# Responsible for building the storage dict into a set of rules ("JSON Schema") acceptable via the API -# Probably other ways to solve this when the backend switches to some ORM -from changedetectionio.notification import valid_notification_formats - - -def build_time_between_check_json_schema(): - # Setup time between check schema - schema_properties_time_between_check = { - "type": "object", - "additionalProperties": False, - "properties": {} - } - for p in ['weeks', 'days', 'hours', 'minutes', 'seconds']: - schema_properties_time_between_check['properties'][p] = { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ] - } - - return schema_properties_time_between_check - -def build_watch_json_schema(d): - # Base JSON schema - schema = { - 'type': 'object', - 'properties': {}, - } - - for k, v in d.items(): - # @todo 'integer' is not covered here because its almost always for internal usage - - if isinstance(v, type(None)): - schema['properties'][k] = { - "anyOf": [ - {"type": "null"}, - ] - } - elif isinstance(v, list): - schema['properties'][k] = { - "anyOf": [ - {"type": "array", - # Always is an array of strings, like text or regex or something - "items": { - "type": "string", - "maxLength": 5000 - } - }, - ] - } - elif isinstance(v, bool): - schema['properties'][k] = { - "anyOf": [ - {"type": "boolean"}, - ] - } - elif isinstance(v, str): - schema['properties'][k] = { - "anyOf": [ - {"type": "string", - "maxLength": 5000}, - ] - } - - # Can also be a string (or None by default above) - for v in ['body', - 'notification_body', - 'notification_format', - 'notification_title', - 'proxy', - 'tag', - 'title', - 'webdriver_js_execute_code' - ]: - schema['properties'][v]['anyOf'].append({'type': 'string', "maxLength": 5000}) - - for v in ['last_viewed']: - schema['properties'][v] = { - "type": "integer", - "description": "Unix timestamp in seconds of the last time the watch was viewed.", - "minimum": 0 - } - - # None or Boolean - schema['properties']['track_ldjson_price_data']['anyOf'].append({'type': 'boolean'}) - - schema['properties']['method'] = {"type": "string", - "enum": ["GET", "POST", "DELETE", "PUT"] - } - - schema['properties']['fetch_backend']['anyOf'].append({"type": "string", - "enum": ["html_requests", "html_webdriver"] - }) - - schema['properties']['processor'] = {"anyOf": [ - {"type": "string", "enum": ["restock_diff", "text_json_diff"]}, - {"type": "null"} - ]} - - # All headers must be key/value type dict - schema['properties']['headers'] = { - "type": "object", - "patternProperties": { - # Should always be a string:string type value - ".*": {"type": "string"}, - } - } - - schema['properties']['notification_format'] = {'type': 'string', - 'enum': list(valid_notification_formats.keys()) - } - - # Stuff that shouldn't be available but is just state-storage - for v in ['previous_md5', 'last_error', 'has_ldjson_price_data', 'previous_md5_before_filters', 'uuid']: - del schema['properties'][v] - - schema['properties']['webdriver_delay']['anyOf'].append({'type': 'integer'}) - - schema['properties']['time_between_check'] = build_time_between_check_json_schema() - - schema['properties']['time_between_check_use_default'] = { - "type": "boolean", - "default": True, - "description": "Whether to use global settings for time between checks - defaults to true if not set" - } - - schema['properties']['browser_steps'] = { - "anyOf": [ - { - "type": "array", - "items": { - "type": "object", - "properties": { - "operation": { - "type": ["string", "null"], - "maxLength": 5000 # Allows null and any string up to 5000 chars (including "") - }, - "selector": { - "type": ["string", "null"], - "maxLength": 5000 - }, - "optional_value": { - "type": ["string", "null"], - "maxLength": 5000 - } - }, - "required": ["operation", "selector", "optional_value"], - "additionalProperties": False # No extra keys allowed - } - }, - {"type": "null"}, # Allows null for `browser_steps` - {"type": "array", "maxItems": 0} # Allows empty array [] - ] - } - - # headers ? - return schema - diff --git a/requirements.txt b/requirements.txt index 595b9fab4..eca1ea19d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,6 @@ flask-compress # 0.6.3 included compatibility fix for werkzeug 3.x (2.x had deprecation of url handlers) flask-login>=0.6.3 flask-paginate -flask_expects_json~=1.7 flask_restful flask_cors # For the Chrome extension to operate # janus # No longer needed - using pure threading.Queue for multi-loop support @@ -126,8 +125,8 @@ greenlet >= 3.0.3 # Default SOCKETIO_MODE=threading is recommended for better compatibility gevent -# Pinned or it causes problems with flask_expects_json which seems unmaintained -referencing==0.35.1 +# Previously pinned for flask_expects_json (removed 2026-02). Unpinning for now. +referencing # For conditions panzi-json-logic