mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-10-30 06:07:50 +00:00
API - Use OpenAPI docs (#3384)
This commit is contained in:
@@ -280,7 +280,10 @@ Excel import is recommended - that way you can better organise tags/groups of we
|
||||
|
||||
## API Support
|
||||
|
||||
Supports managing the website watch list [via our API](https://changedetection.io/docs/api_v1/index.html)
|
||||
Full REST API for programmatic management of watches, tags, notifications and more.
|
||||
|
||||
- **[Interactive API Documentation](https://changedetection.io/docs/api_v1/index.html)** - Complete API reference with live testing
|
||||
- **[OpenAPI Specification](docs/api-spec.yaml)** - Generate SDKs for any programming language
|
||||
|
||||
## Support us
|
||||
|
||||
|
||||
@@ -13,16 +13,7 @@ class Import(Resource):
|
||||
|
||||
@auth.check_token
|
||||
def post(self):
|
||||
"""
|
||||
@api {post} /api/v1/import Import a list of watched URLs
|
||||
@apiDescription Accepts a line-feed separated list of URLs to import, additionally with ?tag_uuids=(tag id), ?tag=(name), ?proxy={key}, ?dedupe=true (default true) one URL per line.
|
||||
@apiExample {curl} Example usage:
|
||||
curl http://localhost:5000/api/v1/import --data-binary @list-of-sites.txt -H"x-api-key:8a111a21bc2f8f1dd9b9353bbd46049a"
|
||||
@apiName Import
|
||||
@apiGroup Import
|
||||
@apiSuccess (200) {List} OK List of watch UUIDs added
|
||||
@apiSuccess (500) {String} ERR Some other error
|
||||
"""
|
||||
"""Import a list of watched URLs."""
|
||||
|
||||
extras = {}
|
||||
|
||||
|
||||
@@ -13,18 +13,7 @@ class Notifications(Resource):
|
||||
|
||||
@auth.check_token
|
||||
def get(self):
|
||||
"""
|
||||
@api {get} /api/v1/notifications Return Notification URL List
|
||||
@apiDescription Return the Notification URL List from the configuration
|
||||
@apiExample {curl} Example usage:
|
||||
curl http://localhost:5000/api/v1/notifications -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
HTTP/1.0 200
|
||||
{
|
||||
'notification_urls': ["notification-urls-list"]
|
||||
}
|
||||
@apiName Get
|
||||
@apiGroup Notifications
|
||||
"""
|
||||
"""Return Notification URL List."""
|
||||
|
||||
notification_urls = self.datastore.data.get('settings', {}).get('application', {}).get('notification_urls', [])
|
||||
|
||||
@@ -35,16 +24,7 @@ class Notifications(Resource):
|
||||
@auth.check_token
|
||||
@expects_json(schema_create_notification_urls)
|
||||
def post(self):
|
||||
"""
|
||||
@api {post} /api/v1/notifications Create Notification URLs
|
||||
@apiDescription Add one or more notification URLs from the configuration
|
||||
@apiExample {curl} Example usage:
|
||||
curl http://localhost:5000/api/v1/notifications/batch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
|
||||
@apiName CreateBatch
|
||||
@apiGroup Notifications
|
||||
@apiSuccess (201) {Object[]} notification_urls List of added notification URLs
|
||||
@apiError (400) {String} Invalid input
|
||||
"""
|
||||
"""Create Notification URLs."""
|
||||
|
||||
json_data = request.get_json()
|
||||
notification_urls = json_data.get("notification_urls", [])
|
||||
@@ -71,16 +51,7 @@ class Notifications(Resource):
|
||||
@auth.check_token
|
||||
@expects_json(schema_create_notification_urls)
|
||||
def put(self):
|
||||
"""
|
||||
@api {put} /api/v1/notifications Replace Notification URLs
|
||||
@apiDescription Replace all notification URLs with the provided list (can be empty)
|
||||
@apiExample {curl} Example usage:
|
||||
curl -X PUT http://localhost:5000/api/v1/notifications -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
|
||||
@apiName Replace
|
||||
@apiGroup Notifications
|
||||
@apiSuccess (200) {Object[]} notification_urls List of current notification URLs
|
||||
@apiError (400) {String} Invalid input
|
||||
"""
|
||||
"""Replace Notification URLs."""
|
||||
json_data = request.get_json()
|
||||
notification_urls = json_data.get("notification_urls", [])
|
||||
|
||||
@@ -102,17 +73,7 @@ class Notifications(Resource):
|
||||
@auth.check_token
|
||||
@expects_json(schema_delete_notification_urls)
|
||||
def delete(self):
|
||||
"""
|
||||
@api {delete} /api/v1/notifications Delete Notification URLs
|
||||
@apiDescription Deletes one or more notification URLs from the configuration
|
||||
@apiExample {curl} Example usage:
|
||||
curl http://localhost:5000/api/v1/notifications -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"notification_urls": ["url1", "url2"]}'
|
||||
@apiParam {String[]} notification_urls The notification URLs to delete.
|
||||
@apiName Delete
|
||||
@apiGroup Notifications
|
||||
@apiSuccess (204) {String} OK Deleted
|
||||
@apiError (400) {String} No matching notification URLs found.
|
||||
"""
|
||||
"""Delete Notification URLs."""
|
||||
|
||||
json_data = request.get_json()
|
||||
urls_to_delete = json_data.get("notification_urls", [])
|
||||
|
||||
@@ -9,20 +9,7 @@ class Search(Resource):
|
||||
|
||||
@auth.check_token
|
||||
def get(self):
|
||||
"""
|
||||
@api {get} /api/v1/search Search for watches
|
||||
@apiDescription Search watches by URL or title text
|
||||
@apiExample {curl} Example usage:
|
||||
curl "http://localhost:5000/api/v1/search?q=https://example.com/page1" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
curl "http://localhost:5000/api/v1/search?q=https://example.com/page1?tag=Favourites" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
curl "http://localhost:5000/api/v1/search?q=https://example.com?partial=true" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
@apiName Search
|
||||
@apiGroup Search
|
||||
@apiQuery {String} q Search query to match against watch URLs and titles
|
||||
@apiQuery {String} [tag] Optional name of tag to limit results (name not UUID)
|
||||
@apiQuery {String} [partial] Allow partial matching of URL query
|
||||
@apiSuccess (200) {Object} JSON Object containing matched watches
|
||||
"""
|
||||
"""Search for watches by URL or title text."""
|
||||
query = request.args.get('q', '').strip()
|
||||
tag_limit = request.args.get('tag', '').strip()
|
||||
from changedetectionio.strtobool import strtobool
|
||||
|
||||
@@ -10,22 +10,7 @@ class SystemInfo(Resource):
|
||||
|
||||
@auth.check_token
|
||||
def get(self):
|
||||
"""
|
||||
@api {get} /api/v1/systeminfo Return system info
|
||||
@apiDescription Return some info about the current system state
|
||||
@apiExample {curl} Example usage:
|
||||
curl http://localhost:5000/api/v1/systeminfo -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
HTTP/1.0 200
|
||||
{
|
||||
'queue_size': 10 ,
|
||||
'overdue_watches': ["watch-uuid-list"],
|
||||
'uptime': 38344.55,
|
||||
'watch_count': 800,
|
||||
'version': "0.40.1"
|
||||
}
|
||||
@apiName Get Info
|
||||
@apiGroup System Information
|
||||
"""
|
||||
"""Return system info."""
|
||||
import time
|
||||
overdue_watches = []
|
||||
|
||||
|
||||
@@ -20,21 +20,7 @@ class Tag(Resource):
|
||||
# curl http://localhost:5000/api/v1/tag/<string:uuid>
|
||||
@auth.check_token
|
||||
def get(self, uuid):
|
||||
"""
|
||||
@api {get} /api/v1/tag/:uuid Single tag / group - Get data, toggle notification muting, recheck all.
|
||||
@apiDescription Retrieve tag information, set notification_muted status, recheck all in tag.
|
||||
@apiExampleRequest
|
||||
curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
curl "http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=muted" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
curl "http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091?recheck=true" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
@apiName Tag
|
||||
@apiGroup Group / Tag
|
||||
@apiParam {uuid} uuid Tag unique ID.
|
||||
@apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state
|
||||
@apiQuery {String} [recheck] = True, Queue all watches with this tag for recheck
|
||||
@apiSuccess (200) {String} OK When muted operation OR full JSON object of the tag
|
||||
@apiSuccess (200) {JSON} TagJSON JSON Full JSON object of the tag
|
||||
"""
|
||||
"""Get data for a single tag/group, toggle notification muting, or recheck all."""
|
||||
from copy import deepcopy
|
||||
tag = deepcopy(self.datastore.data['settings']['application']['tags'].get(uuid))
|
||||
if not tag:
|
||||
@@ -65,17 +51,7 @@ class Tag(Resource):
|
||||
|
||||
@auth.check_token
|
||||
def delete(self, uuid):
|
||||
"""
|
||||
@api {delete} /api/v1/tag/:uuid Delete a tag / group and remove it from all watches
|
||||
@apiExampleRequest {curl} Example usage:
|
||||
curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
@apiExampleResponse {string}
|
||||
OK
|
||||
@apiParam {uuid} uuid Tag unique ID.
|
||||
@apiName DeleteTag
|
||||
@apiGroup Group / Tag
|
||||
@apiSuccess (200) {String} OK Was deleted
|
||||
"""
|
||||
"""Delete a tag/group and remove it from all watches."""
|
||||
if not self.datastore.data['settings']['application']['tags'].get(uuid):
|
||||
abort(400, message='No tag exists with the UUID of {}'.format(uuid))
|
||||
|
||||
@@ -92,19 +68,7 @@ class Tag(Resource):
|
||||
@auth.check_token
|
||||
@expects_json(schema_update_tag)
|
||||
def put(self, uuid):
|
||||
"""
|
||||
@api {put} /api/v1/tag/:uuid Update tag information
|
||||
@apiExampleRequest {curl} Request:
|
||||
curl http://localhost:5000/api/v1/tag/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"title": "New Tag Title"}'
|
||||
@apiExampleResponse {json} Response:
|
||||
"OK"
|
||||
@apiDescription Updates an existing tag using JSON
|
||||
@apiParam {uuid} uuid Tag unique ID.
|
||||
@apiName UpdateTag
|
||||
@apiGroup Group / Tag
|
||||
@apiSuccess (200) {String} OK Was updated
|
||||
@apiSuccess (500) {String} ERR Some other error
|
||||
"""
|
||||
"""Update tag information."""
|
||||
tag = self.datastore.data['settings']['application']['tags'].get(uuid)
|
||||
if not tag:
|
||||
abort(404, message='No tag exists with the UUID of {}'.format(uuid))
|
||||
@@ -118,15 +82,7 @@ class Tag(Resource):
|
||||
@auth.check_token
|
||||
# Only cares for {'title': 'xxxx'}
|
||||
def post(self):
|
||||
"""
|
||||
@api {post} /api/v1/watch Create a single tag / group
|
||||
@apiExample {curl} Example usage:
|
||||
curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"name": "Work related"}'
|
||||
@apiName Create
|
||||
@apiGroup Group / Tag
|
||||
@apiSuccess (200) {String} OK Was created
|
||||
@apiSuccess (500) {String} ERR Some other error
|
||||
"""
|
||||
"""Create a single tag/group."""
|
||||
|
||||
json_data = request.get_json()
|
||||
title = json_data.get("title",'').strip()
|
||||
@@ -145,28 +101,7 @@ class Tags(Resource):
|
||||
|
||||
@auth.check_token
|
||||
def get(self):
|
||||
"""
|
||||
@api {get} /api/v1/tags List tags / groups
|
||||
@apiDescription Return list of available tags / groups
|
||||
@apiExampleRequest {curl} Request:
|
||||
curl http://localhost:5000/api/v1/tags -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
@apiExampleResponse {json} Response:
|
||||
{
|
||||
"cc0cfffa-f449-477b-83ea-0caafd1dc091": {
|
||||
"title": "Tech News",
|
||||
"notification_muted": false,
|
||||
"date_created": 1677103794
|
||||
},
|
||||
"e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": {
|
||||
"title": "Shopping",
|
||||
"notification_muted": true,
|
||||
"date_created": 1676662819
|
||||
}
|
||||
}
|
||||
@apiName ListTags
|
||||
@apiGroup Group / Tag Management
|
||||
@apiSuccess (200) {JSON} Short list of tags keyed by tag/group UUID
|
||||
"""
|
||||
"""List tags/groups."""
|
||||
result = {}
|
||||
for uuid, tag in self.datastore.data['settings']['application']['tags'].items():
|
||||
result[uuid] = {
|
||||
|
||||
@@ -26,23 +26,7 @@ class Watch(Resource):
|
||||
# ?recheck=true
|
||||
@auth.check_token
|
||||
def get(self, uuid):
|
||||
"""
|
||||
@api {get} /api/v1/watch/:uuid Single watch - get data, recheck, pause, mute.
|
||||
@apiDescription Retrieve watch information and set muted/paused status, returns the FULL Watch JSON which can be used for any other PUT (update etc)
|
||||
@apiExample {curl} Example usage:
|
||||
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
curl "http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091?muted=unmuted" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
curl "http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091?paused=unpaused" -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
@apiName Watch
|
||||
@apiGroup Watch
|
||||
@apiGroupDocOrder 0
|
||||
@apiParam {uuid} uuid Watch unique ID.
|
||||
@apiQuery {Boolean} [recheck] Recheck this watch `recheck=1`
|
||||
@apiQuery {String} [paused] =`paused` or =`unpaused` , Sets the PAUSED state
|
||||
@apiQuery {String} [muted] =`muted` or =`unmuted` , Sets the MUTE NOTIFICATIONS state
|
||||
@apiSuccess (200) {String} OK When paused/muted/recheck operation OR full JSON object of the watch
|
||||
@apiSuccess (200) {JSON} WatchJSON JSON Full JSON object of the watch
|
||||
"""
|
||||
"""Get information about a single watch, recheck, pause, or mute."""
|
||||
from copy import deepcopy
|
||||
watch = deepcopy(self.datastore.data['watching'].get(uuid))
|
||||
if not watch:
|
||||
@@ -74,15 +58,7 @@ class Watch(Resource):
|
||||
|
||||
@auth.check_token
|
||||
def delete(self, uuid):
|
||||
"""
|
||||
@api {delete} /api/v1/watch/:uuid Delete a watch and related history
|
||||
@apiExample {curl} Example usage:
|
||||
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X DELETE -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
@apiParam {uuid} uuid Watch unique ID.
|
||||
@apiName Delete
|
||||
@apiGroup Watch
|
||||
@apiSuccess (200) {String} OK Was deleted
|
||||
"""
|
||||
"""Delete a watch and related history."""
|
||||
if not self.datastore.data['watching'].get(uuid):
|
||||
abort(400, message='No watch exists with the UUID of {}'.format(uuid))
|
||||
|
||||
@@ -92,19 +68,7 @@ class Watch(Resource):
|
||||
@auth.check_token
|
||||
@expects_json(schema_update_watch)
|
||||
def put(self, uuid):
|
||||
"""
|
||||
@api {put} /api/v1/watch/:uuid Update watch information
|
||||
@apiExampleRequest {curl} Example usage:
|
||||
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091 -X PUT -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "new list"}'
|
||||
@apiExampleResponse {string} Example usage:
|
||||
OK
|
||||
@apiDescription Updates an existing watch using JSON, accepts the same structure as returned in <a href="#watch_GET">get single watch information</a>
|
||||
@apiParam {uuid} uuid Watch unique ID.
|
||||
@apiName Update a watch
|
||||
@apiGroup Watch
|
||||
@apiSuccess (200) {String} OK Was updated
|
||||
@apiSuccess (500) {String} ERR Some other error
|
||||
"""
|
||||
"""Update watch information."""
|
||||
watch = self.datastore.data['watching'].get(uuid)
|
||||
if not watch:
|
||||
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
||||
@@ -128,23 +92,7 @@ class WatchHistory(Resource):
|
||||
# curl http://localhost:5000/api/v1/watch/<string:uuid>/history
|
||||
@auth.check_token
|
||||
def get(self, uuid):
|
||||
"""
|
||||
@api {get} /api/v1/watch/<string:uuid>/history Get a list of all historical snapshots available for a watch
|
||||
@apiDescription Requires `uuid`, returns list
|
||||
@apiGroupDocOrder 1
|
||||
@apiExampleRequest {curl} Request:
|
||||
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json"
|
||||
@apiExampleResponse {json} Response:
|
||||
{
|
||||
"1676649279": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/cb7e9be8258368262246910e6a2a4c30.txt",
|
||||
"1677092785": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/e20db368d6fc633e34f559ff67bb4044.txt",
|
||||
"1677103794": "/tmp/data/6a4b7d5c-fee4-4616-9f43-4ac97046b595/02efdd37dacdae96554a8cc85dc9c945.txt"
|
||||
}
|
||||
@apiName Get list of available stored snapshots for watch
|
||||
@apiGroup Watch History
|
||||
@apiSuccess (200) {JSON} List of keyed (by change date) paths to snapshot, use the key to <a href="#snapshots_GET">fetch a single snapshot</a>.
|
||||
@apiSuccess (404) {String} ERR Not found
|
||||
"""
|
||||
"""Get a list of all historical snapshots available for a watch."""
|
||||
watch = self.datastore.data['watching'].get(uuid)
|
||||
if not watch:
|
||||
abort(404, message='No watch exists with the UUID of {}'.format(uuid))
|
||||
@@ -158,20 +106,7 @@ class WatchSingleHistory(Resource):
|
||||
|
||||
@auth.check_token
|
||||
def get(self, uuid, timestamp):
|
||||
"""
|
||||
@api {get} /api/v1/watch/<string:uuid>/history/<int:timestamp> Get single snapshot from watch
|
||||
@apiDescription Requires watch `uuid` and `timestamp`. `timestamp` of "`latest`" for latest available snapshot, or <a href="#watch_history_GET">use the list returned here</a>
|
||||
@apiExampleRequest {curl} Example usage:
|
||||
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history/1677092977 -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json"
|
||||
@apiExampleResponse {string} Closes matching snapshot text
|
||||
Big bad fox flew over the moon at 2025-01-01 etc etc
|
||||
@apiName Get single snapshot content
|
||||
@apiGroup Snapshots
|
||||
@apiGroupDocOrder 2
|
||||
@apiParam {String} [html] Optional Set to =1 to return the last HTML (only stores last 2 snapshots, use `latest` as timestamp)
|
||||
@apiSuccess (200) {String} OK
|
||||
@apiSuccess (404) {String} ERR Not found
|
||||
"""
|
||||
"""Get single snapshot from watch."""
|
||||
watch = self.datastore.data['watching'].get(uuid)
|
||||
if not watch:
|
||||
abort(404, message=f"No watch exists with the UUID of {uuid}")
|
||||
@@ -204,19 +139,7 @@ class WatchFavicon(Resource):
|
||||
|
||||
@auth.check_token
|
||||
def get(self, uuid):
|
||||
"""
|
||||
@api {get} /api/v1/watch/<string:uuid>/favicon Get favicon for a watch.
|
||||
@apiDescription Requires watch `uuid`, ,The favicon is the favicon which is available in the page watch overview list.
|
||||
@apiExampleRequest {curl} Example usage:
|
||||
curl http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/favicon -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
@apiExampleResponse {binary data}
|
||||
JPEG...
|
||||
@apiName Get latest Favicon
|
||||
@apiGroup Favicon
|
||||
@apiGroupDocOrder 3
|
||||
@apiSuccess (200) {binary} Data ( Binary data of the favicon )
|
||||
@apiSuccess (404) {String} ERR Not found
|
||||
"""
|
||||
"""Get favicon for a watch."""
|
||||
watch = self.datastore.data['watching'].get(uuid)
|
||||
if not watch:
|
||||
abort(404, message=f"No watch exists with the UUID of {uuid}")
|
||||
@@ -251,16 +174,7 @@ class CreateWatch(Resource):
|
||||
@auth.check_token
|
||||
@expects_json(schema_create_watch)
|
||||
def post(self):
|
||||
"""
|
||||
@api {post} /api/v1/watch Create a single watch
|
||||
@apiDescription Requires atleast `url` set, can accept the same structure as <a href="#watch_GET">get single watch information</a> to create.
|
||||
@apiExample {curl} Example usage:
|
||||
curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45" -H "Content-Type: application/json" -d '{"url": "https://my-nice.com" , "tag": "nice list"}'
|
||||
@apiName Create
|
||||
@apiGroup Watch
|
||||
@apiSuccess (200) {String} OK Was created
|
||||
@apiSuccess (500) {String} ERR Some other error
|
||||
"""
|
||||
"""Create a single watch."""
|
||||
|
||||
json_data = request.get_json()
|
||||
url = json_data['url'].strip()
|
||||
@@ -294,36 +208,7 @@ class CreateWatch(Resource):
|
||||
|
||||
@auth.check_token
|
||||
def get(self):
|
||||
"""
|
||||
@api {get} /api/v1/watch List watches
|
||||
@apiDescription Return concise list of available watches and some very basic info
|
||||
@apiExampleRequest {curl} Request:
|
||||
curl http://localhost:5000/api/v1/watch -H"x-api-key:813031b16330fe25e3780cf0325daa45"
|
||||
@apiExampleResponse {json} Response:
|
||||
{
|
||||
"6a4b7d5c-fee4-4616-9f43-4ac97046b595": {
|
||||
"last_changed": 1677103794,
|
||||
"last_checked": 1677103794,
|
||||
"last_error": false,
|
||||
"title": "",
|
||||
"url": "http://www.quotationspage.com/random.php"
|
||||
},
|
||||
"e6f5fd5c-dbfe-468b-b8f3-f9d6ff5ad69b": {
|
||||
"last_changed": 0,
|
||||
"last_checked": 1676662819,
|
||||
"last_error": false,
|
||||
"title": "QuickLook",
|
||||
"url": "https://github.com/QL-Win/QuickLook/tags"
|
||||
}
|
||||
}
|
||||
|
||||
@apiParam {String} [recheck_all] Optional Set to =1 to force recheck of all watches
|
||||
@apiParam {String} [tag] Optional name of tag to limit results
|
||||
@apiName ListWatches
|
||||
@apiGroup Watch Management
|
||||
@apiGroupDocOrder 4
|
||||
@apiSuccess (200) {String} OK JSON dict
|
||||
"""
|
||||
"""List watches."""
|
||||
list = {}
|
||||
|
||||
tag_limit = request.args.get('tag', '').lower()
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
Directory of docs
|
||||
|
||||
To regenerate API docs
|
||||
## Regenerating API Documentation
|
||||
|
||||
Run from this directory.
|
||||
### Modern Interactive API Docs (Recommended)
|
||||
|
||||
To regenerate the modern API documentation, run from the `docs/` directory:
|
||||
|
||||
```bash
|
||||
# Install dependencies (first time only)
|
||||
npm install
|
||||
|
||||
# Generate the HTML documentation from OpenAPI spec using Redoc
|
||||
npm run build-docs
|
||||
```
|
||||
|
||||
### OpenAPI Specification
|
||||
|
||||
The OpenAPI specification (`docs/api-spec.yaml`) is the source of truth for API documentation. This industry-standard format enables:
|
||||
|
||||
- **Interactive documentation** - Test endpoints directly in the browser
|
||||
- **SDK generation** - Auto-generate client libraries for any programming language
|
||||
- **API validation** - Ensure code matches documentation
|
||||
- **Integration tools** - Import into Postman, Insomnia, API gateways, etc.
|
||||
|
||||
**Important:** When adding or modifying API endpoints, you must update `docs/api-spec.yaml` to keep documentation in sync:
|
||||
|
||||
1. Edit `docs/api-spec.yaml` with new endpoints, parameters, or response schemas
|
||||
2. Run `npm run build-docs` to regenerate the HTML documentation
|
||||
3. Commit both the YAML spec and generated HTML files
|
||||
|
||||
`python3 python-apidoc/apidoc.py -i ../changedetectionio -o api_v1/index.html`
|
||||
|
||||
|
||||
|
||||
1266
docs/api-spec.yaml
Normal file
1266
docs/api-spec.yaml
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
12
docs/package.json
Normal file
12
docs/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "changedetection-api-docs",
|
||||
"version": "1.0.0",
|
||||
"description": "API documentation generation for changedetection.io",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build-docs": "redocly build-docs api-spec.yaml --output api_v1/index.html"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "^1.34.5"
|
||||
}
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Python API Documentation Generator
|
||||
Parses @api comments from Python files and generates Bootstrap HTML docs
|
||||
"""
|
||||
|
||||
import re
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Any
|
||||
from jinja2 import Template
|
||||
|
||||
@dataclass
|
||||
class ApiEndpoint:
|
||||
method: str = ""
|
||||
url: str = ""
|
||||
title: str = ""
|
||||
name: str = ""
|
||||
group: str = "General"
|
||||
group_order: int = 999 # Default to high number (low priority)
|
||||
group_doc_order: int = 999 # Default to high number (low priority) for sidebar ordering
|
||||
description: str = ""
|
||||
params: List[Dict[str, Any]] = field(default_factory=list)
|
||||
query: List[Dict[str, Any]] = field(default_factory=list)
|
||||
success: List[Dict[str, Any]] = field(default_factory=list)
|
||||
error: List[Dict[str, Any]] = field(default_factory=list)
|
||||
example: str = ""
|
||||
example_request: str = ""
|
||||
example_response: str = ""
|
||||
|
||||
def prettify_json(text: str) -> str:
|
||||
"""Attempt to prettify JSON content in the text"""
|
||||
if not text or not text.strip():
|
||||
return text
|
||||
|
||||
# First, try to parse the entire text as JSON
|
||||
stripped_text = text.strip()
|
||||
try:
|
||||
json_obj = json.loads(stripped_text)
|
||||
return json.dumps(json_obj, indent=2, ensure_ascii=False)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# If that fails, try to find JSON blocks within the text
|
||||
lines = text.split('\n')
|
||||
prettified_lines = []
|
||||
i = 0
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped_line = line.strip()
|
||||
|
||||
# Look for the start of a JSON object or array
|
||||
if stripped_line.startswith('{') or stripped_line.startswith('['):
|
||||
# Try to collect a complete JSON block
|
||||
json_lines = [stripped_line]
|
||||
brace_count = stripped_line.count('{') - stripped_line.count('}')
|
||||
bracket_count = stripped_line.count('[') - stripped_line.count(']')
|
||||
|
||||
j = i + 1
|
||||
while j < len(lines) and (brace_count > 0 or bracket_count > 0):
|
||||
next_line = lines[j].strip()
|
||||
json_lines.append(next_line)
|
||||
brace_count += next_line.count('{') - next_line.count('}')
|
||||
bracket_count += next_line.count('[') - next_line.count(']')
|
||||
j += 1
|
||||
|
||||
# Try to parse and prettify the collected JSON block
|
||||
json_block = '\n'.join(json_lines)
|
||||
try:
|
||||
json_obj = json.loads(json_block)
|
||||
prettified = json.dumps(json_obj, indent=2, ensure_ascii=False)
|
||||
prettified_lines.append(prettified)
|
||||
i = j # Skip the lines we just processed
|
||||
continue
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# If parsing failed, just add the original line
|
||||
prettified_lines.append(line)
|
||||
else:
|
||||
prettified_lines.append(line)
|
||||
|
||||
i += 1
|
||||
|
||||
return '\n'.join(prettified_lines)
|
||||
|
||||
class ApiDocParser:
|
||||
def __init__(self):
|
||||
self.patterns = {
|
||||
'api': re.compile(r'@api\s*\{(\w+)\}\s*([^\s]+)\s*(.*)'),
|
||||
'apiName': re.compile(r'@apiName\s+(.*)'),
|
||||
'apiGroup': re.compile(r'@apiGroup\s+(.*)'),
|
||||
'apiGroupOrder': re.compile(r'@apiGroupOrder\s+(\d+)'),
|
||||
'apiGroupDocOrder': re.compile(r'@apiGroupDocOrder\s+(\d+)'),
|
||||
'apiDescription': re.compile(r'@apiDescription\s+(.*)'),
|
||||
'apiParam': re.compile(r'@apiParam\s*\{([^}]+)\}\s*(\[?[\w.:]+\]?)\s*(.*)'),
|
||||
'apiQuery': re.compile(r'@apiQuery\s*\{([^}]+)\}\s*(\[?[\w.:]+\]?)\s*(.*)'),
|
||||
'apiSuccess': re.compile(r'@apiSuccess\s*\((\d+)\)\s*\{([^}]+)\}\s*(\w+)?\s*(.*)'),
|
||||
'apiError': re.compile(r'@apiError\s*\((\d+)\)\s*\{([^}]+)\}\s*(.*)'),
|
||||
'apiExample': re.compile(r'@apiExample\s*\{([^}]+)\}\s*(.*)'),
|
||||
'apiExampleRequest': re.compile(r'@apiExampleRequest\s*\{([^}]+)\}\s*(.*)'),
|
||||
'apiExampleResponse': re.compile(r'@apiExampleResponse\s*\{([^}]+)\}\s*(.*)'),
|
||||
}
|
||||
|
||||
def parse_file(self, file_path: Path) -> List[ApiEndpoint]:
|
||||
"""Parse a single Python file for @api comments"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
except Exception as e:
|
||||
print(f"Error reading {file_path}: {e}")
|
||||
return []
|
||||
|
||||
endpoints = []
|
||||
current_endpoint = None
|
||||
in_multiline_example = False
|
||||
in_multiline_request = False
|
||||
in_multiline_response = False
|
||||
example_lines = []
|
||||
request_lines = []
|
||||
response_lines = []
|
||||
|
||||
for line in content.split('\n'):
|
||||
line_stripped = line.strip()
|
||||
|
||||
# Handle multiline examples, requests, and responses
|
||||
if in_multiline_example or in_multiline_request or in_multiline_response:
|
||||
# Check if this line starts a new example type or exits multiline mode
|
||||
should_exit_multiline = False
|
||||
|
||||
if line_stripped.startswith('@apiExampleRequest'):
|
||||
# Finalize current multiline block and start request
|
||||
should_exit_multiline = True
|
||||
elif line_stripped.startswith('@apiExampleResponse'):
|
||||
# Finalize current multiline block and start response
|
||||
should_exit_multiline = True
|
||||
elif line_stripped.startswith('@apiExample'):
|
||||
# Finalize current multiline block and start example
|
||||
should_exit_multiline = True
|
||||
elif line_stripped.startswith('@api') and not any(x in line_stripped for x in ['@apiExample', '@apiExampleRequest', '@apiExampleResponse']):
|
||||
# Exit multiline mode for any other @api directive
|
||||
should_exit_multiline = True
|
||||
|
||||
if should_exit_multiline:
|
||||
# Finalize any active multiline blocks
|
||||
if in_multiline_example and current_endpoint and example_lines:
|
||||
current_endpoint.example = '\n'.join(example_lines)
|
||||
if in_multiline_request and current_endpoint and request_lines:
|
||||
current_endpoint.example_request = '\n'.join(request_lines)
|
||||
if in_multiline_response and current_endpoint and response_lines:
|
||||
raw_response = '\n'.join(response_lines)
|
||||
current_endpoint.example_response = prettify_json(raw_response)
|
||||
|
||||
# Reset all multiline states
|
||||
in_multiline_example = False
|
||||
in_multiline_request = False
|
||||
in_multiline_response = False
|
||||
example_lines = []
|
||||
request_lines = []
|
||||
response_lines = []
|
||||
|
||||
# If this is still an example directive, continue processing it
|
||||
if not (line_stripped.startswith('@apiExample') or line_stripped.startswith('@apiExampleRequest') or line_stripped.startswith('@apiExampleResponse')):
|
||||
# This is a different @api directive, let it be processed normally
|
||||
pass
|
||||
# If it's an example directive, it will be processed below
|
||||
else:
|
||||
# For multiline blocks, preserve the content more liberally
|
||||
# Remove leading comment markers but preserve structure
|
||||
clean_line = re.sub(r'^\s*[#*/]*\s?', '', line)
|
||||
# Add the line if it has content or if it's an empty line (for formatting)
|
||||
if clean_line or not line_stripped:
|
||||
if in_multiline_example:
|
||||
example_lines.append(clean_line)
|
||||
elif in_multiline_request:
|
||||
request_lines.append(clean_line)
|
||||
elif in_multiline_response:
|
||||
response_lines.append(clean_line)
|
||||
continue
|
||||
|
||||
# Skip non-comment lines
|
||||
if not any(marker in line_stripped for marker in ['@api', '#', '*', '//']):
|
||||
continue
|
||||
|
||||
# Extract @api patterns
|
||||
for pattern_name, pattern in self.patterns.items():
|
||||
match = pattern.search(line_stripped)
|
||||
if match:
|
||||
if pattern_name == 'api':
|
||||
# Start new endpoint
|
||||
if current_endpoint:
|
||||
endpoints.append(current_endpoint)
|
||||
current_endpoint = ApiEndpoint()
|
||||
current_endpoint.method = match.group(1).lower()
|
||||
current_endpoint.url = match.group(2)
|
||||
current_endpoint.title = match.group(3).strip()
|
||||
|
||||
elif current_endpoint:
|
||||
if pattern_name == 'apiName':
|
||||
current_endpoint.name = match.group(1)
|
||||
elif pattern_name == 'apiGroup':
|
||||
current_endpoint.group = match.group(1)
|
||||
elif pattern_name == 'apiGroupOrder':
|
||||
current_endpoint.group_order = int(match.group(1))
|
||||
elif pattern_name == 'apiGroupDocOrder':
|
||||
current_endpoint.group_doc_order = int(match.group(1))
|
||||
elif pattern_name == 'apiDescription':
|
||||
current_endpoint.description = match.group(1)
|
||||
elif pattern_name == 'apiParam':
|
||||
param_type = match.group(1)
|
||||
param_name = match.group(2).strip('[]')
|
||||
param_desc = match.group(3)
|
||||
optional = '[' in match.group(2)
|
||||
current_endpoint.params.append({
|
||||
'type': param_type,
|
||||
'name': param_name,
|
||||
'description': param_desc,
|
||||
'optional': optional
|
||||
})
|
||||
elif pattern_name == 'apiQuery':
|
||||
param_type = match.group(1)
|
||||
param_name = match.group(2).strip('[]')
|
||||
param_desc = match.group(3)
|
||||
optional = '[' in match.group(2)
|
||||
current_endpoint.query.append({
|
||||
'type': param_type,
|
||||
'name': param_name,
|
||||
'description': param_desc,
|
||||
'optional': optional
|
||||
})
|
||||
elif pattern_name == 'apiSuccess':
|
||||
status_code = match.group(1)
|
||||
response_type = match.group(2)
|
||||
response_name = match.group(3) or 'response'
|
||||
response_desc = match.group(4)
|
||||
current_endpoint.success.append({
|
||||
'status': status_code,
|
||||
'type': response_type,
|
||||
'name': response_name,
|
||||
'description': response_desc
|
||||
})
|
||||
elif pattern_name == 'apiError':
|
||||
status_code = match.group(1)
|
||||
error_type = match.group(2)
|
||||
error_desc = match.group(3)
|
||||
current_endpoint.error.append({
|
||||
'status': status_code,
|
||||
'type': error_type,
|
||||
'description': error_desc
|
||||
})
|
||||
elif pattern_name == 'apiExample':
|
||||
in_multiline_example = True
|
||||
# Skip the "{curl} Example usage:" header line
|
||||
example_lines = []
|
||||
elif pattern_name == 'apiExampleRequest':
|
||||
in_multiline_request = True
|
||||
# Skip the "{curl} Request:" header line
|
||||
request_lines = []
|
||||
elif pattern_name == 'apiExampleResponse':
|
||||
in_multiline_response = True
|
||||
# Skip the "{json} Response:" header line
|
||||
response_lines = []
|
||||
break
|
||||
|
||||
# Don't forget the last endpoint
|
||||
if current_endpoint:
|
||||
if in_multiline_example and example_lines:
|
||||
current_endpoint.example = '\n'.join(example_lines)
|
||||
if in_multiline_request and request_lines:
|
||||
current_endpoint.example_request = '\n'.join(request_lines)
|
||||
if in_multiline_response and response_lines:
|
||||
raw_response = '\n'.join(response_lines)
|
||||
current_endpoint.example_response = prettify_json(raw_response)
|
||||
endpoints.append(current_endpoint)
|
||||
|
||||
return endpoints
|
||||
|
||||
def parse_directory(self, directory: Path) -> List[ApiEndpoint]:
|
||||
"""Parse all Python files in a directory"""
|
||||
all_endpoints = []
|
||||
|
||||
for py_file in directory.rglob('*.py'):
|
||||
endpoints = self.parse_file(py_file)
|
||||
all_endpoints.extend(endpoints)
|
||||
|
||||
return all_endpoints
|
||||
|
||||
def generate_html(endpoints: List[ApiEndpoint], output_file: Path, template_file: Path):
|
||||
"""Generate HTML documentation using Jinja2 template"""
|
||||
|
||||
# Group endpoints by group and collect group orders
|
||||
grouped_endpoints = {}
|
||||
group_orders = {}
|
||||
group_doc_orders = {}
|
||||
|
||||
for endpoint in endpoints:
|
||||
group = endpoint.group
|
||||
if group not in grouped_endpoints:
|
||||
grouped_endpoints[group] = []
|
||||
group_orders[group] = endpoint.group_order
|
||||
group_doc_orders[group] = endpoint.group_doc_order
|
||||
grouped_endpoints[group].append(endpoint)
|
||||
|
||||
# Use the lowest order value for the group (in case of multiple definitions)
|
||||
group_orders[group] = min(group_orders[group], endpoint.group_order)
|
||||
group_doc_orders[group] = min(group_doc_orders[group], endpoint.group_doc_order)
|
||||
|
||||
# Sort groups by doc order for sidebar (0 = highest priority), then by content order, then alphabetically
|
||||
sorted_groups = sorted(grouped_endpoints.items(), key=lambda x: (group_doc_orders[x[0]], group_orders[x[0]], x[0]))
|
||||
|
||||
# Convert back to ordered dict and sort endpoints within each group
|
||||
grouped_endpoints = {}
|
||||
for group, endpoints_list in sorted_groups:
|
||||
endpoints_list.sort(key=lambda x: (x.name, x.url))
|
||||
grouped_endpoints[group] = endpoints_list
|
||||
|
||||
# Load template
|
||||
with open(template_file, 'r', encoding='utf-8') as f:
|
||||
template_content = f.read()
|
||||
|
||||
# Load introduction content
|
||||
introduction_file = template_file.parent / 'introduction.html'
|
||||
introduction_content = ""
|
||||
if introduction_file.exists():
|
||||
with open(introduction_file, 'r', encoding='utf-8') as f:
|
||||
introduction_content = f.read()
|
||||
|
||||
# Load sidebar header content
|
||||
sidebar_header_file = template_file.parent / 'sidebar-header.html'
|
||||
sidebar_header_content = "<h4>API Documentation</h4>" # Default fallback
|
||||
if sidebar_header_file.exists():
|
||||
with open(sidebar_header_file, 'r', encoding='utf-8') as f:
|
||||
sidebar_header_content = f.read()
|
||||
|
||||
template = Template(template_content)
|
||||
html_content = template.render(
|
||||
grouped_endpoints=grouped_endpoints,
|
||||
introduction_content=introduction_content,
|
||||
sidebar_header_content=sidebar_header_content
|
||||
)
|
||||
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Generate API documentation from Python source files')
|
||||
parser.add_argument('-i', '--input', default='.',
|
||||
help='Input directory to scan for Python files (default: current directory)')
|
||||
parser.add_argument('-o', '--output', default='api_docs.html',
|
||||
help='Output HTML file (default: api_docs.html)')
|
||||
parser.add_argument('-t', '--template', default='template.html',
|
||||
help='Template HTML file (default: template.html)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
input_path = Path(args.input)
|
||||
output_path = Path(args.output)
|
||||
template_path = Path(args.template)
|
||||
|
||||
# Make template path relative to script location if not absolute
|
||||
if not template_path.is_absolute():
|
||||
template_path = Path(__file__).parent / template_path
|
||||
|
||||
if not input_path.exists():
|
||||
print(f"Error: Input directory '{input_path}' does not exist")
|
||||
return 1
|
||||
|
||||
if not template_path.exists():
|
||||
print(f"Error: Template file '{template_path}' does not exist")
|
||||
return 1
|
||||
|
||||
print(f"Scanning {input_path} for @api comments...")
|
||||
|
||||
doc_parser = ApiDocParser()
|
||||
endpoints = doc_parser.parse_directory(input_path)
|
||||
|
||||
if not endpoints:
|
||||
print("No API endpoints found!")
|
||||
return 1
|
||||
|
||||
print(f"Found {len(endpoints)} API endpoints")
|
||||
|
||||
# Create output directory if needed
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Generating HTML documentation to {output_path}...")
|
||||
generate_html(endpoints, output_path, template_path)
|
||||
|
||||
print("Documentation generated successfully!")
|
||||
print(f"Open {output_path.resolve()} in your browser to view the docs")
|
||||
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
||||
<div class="introduction-content">
|
||||
<h3>ChangeDetection.io, Web page monitoring and notifications API</h3>
|
||||
<p>REST API for managing Page watches, Group tags, and Notifications.</p>
|
||||
|
||||
<p>changedetection.io can be driven by its built in simple API, in the examples below you will also find <code>curl</code> command line examples to help you.</p>
|
||||
|
||||
<p>
|
||||
<h5>Where to find my API key?</h5>
|
||||
The API key can be easily found under the <strong>SETTINGS</strong> then <strong>API</strong> tab of changedetection.io dashboard.<br>
|
||||
Simply click the API key to automatically copy it to your clipboard.<br><br>
|
||||
<img src="where-to-get-api-key.jpeg" alt="Where to find the API key" title="Where to find the API key" style="max-width: 80%"/>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<h5>Connection URL</h5>
|
||||
The API can be found at <code>/api/v1/</code>, so for example if you run changedetection.io locally on port 5000, then URL would be
|
||||
<code>http://localhost:5000/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history</code>.<br><br>
|
||||
If you are using the hosted/subscription version of changedetection.io, then the URL is based on your login URL, for example.<br>
|
||||
<code>https://<your login url>/api/v1/watch/cc0cfffa-f449-477b-83ea-0caafd1dc091/history</code>
|
||||
</p>
|
||||
<p>
|
||||
<h5>Authentication</h5>
|
||||
Almost all API requests require some authentication, this is provided as an <strong>API Key</strong> in the header of the HTTP request.<br><br>
|
||||
For example;
|
||||
<br><code>x-api-key: YOUR_API_KEY</code><br>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1 +0,0 @@
|
||||
<h4>API Documentation</h4>
|
||||
@@ -1,506 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API Documentation</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #f8f9fa; }
|
||||
.sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; background: white; box-shadow: 2px 0 5px rgba(0,0,0,0.1); }
|
||||
.content { padding: 20px; }
|
||||
.endpoint { margin-bottom: 40px; padding: 20px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.method { font-weight: bold; text-transform: uppercase; padding: 4px 8px; border-radius: 4px; }
|
||||
.method.get { background: #d4edda; color: #155724; }
|
||||
.method.post { background: #cce5ff; color: #004085; }
|
||||
.method.put { background: #fff3cd; color: #856404; }
|
||||
.method.delete { background: #f8d7da; color: #721c24; }
|
||||
.param-table { font-size: 0.9em; }
|
||||
.optional { color: #6c757d; font-style: italic; }
|
||||
.example { background: #f8f9fa; border-left: 4px solid #007bff; }
|
||||
pre { font-size: 0.85em; }
|
||||
.copy-btn { opacity: 0.7; transition: opacity 0.2s ease; }
|
||||
.copy-btn:hover { opacity: 1; }
|
||||
.example:hover .copy-btn { opacity: 1; }
|
||||
.nav-link.active { background-color: #007bff; color: white; font-weight: bold; }
|
||||
.nav-link { transition: all 0.2s ease; }
|
||||
.nav-link:hover:not(.active) { background-color: #e3f2fd; color: #0056b3; }
|
||||
.group-header.active { font-weight: bold; color: #007bff; }
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
.sidebar::-webkit-scrollbar { width: 8px; }
|
||||
.sidebar::-webkit-scrollbar-track { background: #f8f9fa; border-radius: 4px; }
|
||||
.sidebar::-webkit-scrollbar-thumb { background: #dee2e6; border-radius: 4px; }
|
||||
.sidebar::-webkit-scrollbar-thumb:hover { background: #adb5bd; }
|
||||
|
||||
/* Firefox scrollbar */
|
||||
.sidebar { scrollbar-width: thin; scrollbar-color: #dee2e6 #f8f9fa; }
|
||||
|
||||
/* Mobile styles - disable sticky sidebar */
|
||||
@media (max-width: 800px) {
|
||||
.sidebar {
|
||||
position: static !important;
|
||||
height: auto !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- Sidebar -->
|
||||
<div class="col-md-3 sidebar">
|
||||
<div class="p-3">
|
||||
<a href="#introduction" class="text-decoration-none">
|
||||
{{ sidebar_header_content|safe }}
|
||||
</a>
|
||||
<hr>
|
||||
|
||||
{% if introduction_content %}
|
||||
<div class="mb-3">
|
||||
<a href="#introduction" class="text-decoration-none">
|
||||
<h6 class="text-muted">Introduction</h6>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for group, endpoints in grouped_endpoints.items() %}
|
||||
<div class="mb-3">
|
||||
<h6 class="text-muted group-header" data-group="{{ group }}">{{ group }}</h6>
|
||||
{% for endpoint in endpoints %}
|
||||
<div class="ms-2 mb-1">
|
||||
<a href="#{{ group|replace(' ', '_')|replace('/', '')|replace('-', '')|lower }}_{{ endpoint.method|upper }}" class="nav-link py-1 px-2 rounded" data-endpoint="{{ group|replace(' ', '_')|replace('/', '')|replace('-', '')|lower }}_{{ endpoint.method|upper }}">
|
||||
<span class="method {{ endpoint.method }}">{{ endpoint.method }}</span>
|
||||
{{ endpoint.title or endpoint.name or endpoint.description }}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="col-md-9 content">
|
||||
{% if introduction_content %}
|
||||
<div id="introduction" class="mb-5">
|
||||
{{ introduction_content|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for group, endpoints in grouped_endpoints.items() %}
|
||||
<h2 class="text-primary mb-4" id="group-{{ group|replace(' ', '_')|lower }}">{{ group }}</h2>
|
||||
|
||||
{% for endpoint in endpoints %}
|
||||
<div class="endpoint" id="{{ group|replace(' ', '_')|replace('/', '')|replace('-', '')|lower }}_{{ endpoint.method|upper }}" data-group="{{ group }}">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<h4>
|
||||
<span class="method {{ endpoint.method }}">{{ endpoint.method }}</span>
|
||||
<code>{{ endpoint.url|e }}</code>
|
||||
</h4>
|
||||
<h5 class="text-muted">{{ endpoint.name or endpoint.title }}</h5>
|
||||
{% if endpoint.description %}
|
||||
<p class="mt-3">{{ endpoint.description|safe }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if endpoint.params %}
|
||||
<h6 class="mt-4">Parameters</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm param-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Required</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for param in endpoint.params %}
|
||||
<tr>
|
||||
<td><code>{{ param.name }}</code></td>
|
||||
<td><span class="badge bg-secondary">{{ param.type }}</span></td>
|
||||
<td>{% if param.optional %}<span class="optional">Optional</span>{% else %}<span class="text-danger">Required</span>{% endif %}</td>
|
||||
<td>{{ param.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if endpoint.query %}
|
||||
<h6 class="mt-4">Query Parameters</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm param-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Required</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for param in endpoint.query %}
|
||||
<tr>
|
||||
<td><code>{{ param.name }}</code></td>
|
||||
<td><span class="badge bg-info">{{ param.type }}</span></td>
|
||||
<td>{% if param.optional %}<span class="optional">Optional</span>{% else %}<span class="text-danger">Required</span>{% endif %}</td>
|
||||
<td>{{ param.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if endpoint.success %}
|
||||
<h6 class="mt-4">Success Responses</h6>
|
||||
{% for success in endpoint.success %}
|
||||
<div class="mb-2">
|
||||
<span class="badge bg-success">{{ success.status }}</span>
|
||||
<span class="badge bg-secondary ms-1">{{ success.type }}</span>
|
||||
<strong class="ms-2">{{ success.name }}</strong>
|
||||
<span class="ms-2 text-muted">{{ success.description }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if endpoint.error %}
|
||||
<h6 class="mt-4">Error Responses</h6>
|
||||
{% for error in endpoint.error %}
|
||||
<div class="mb-2">
|
||||
<span class="badge bg-danger">{{ error.status }}</span>
|
||||
<span class="badge bg-secondary ms-1">{{ error.type }}</span>
|
||||
<span class="ms-2 text-muted">{{ error.description }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if endpoint.example or endpoint.example_request or endpoint.example_response %}
|
||||
<h6 class="mt-4">Example</h6>
|
||||
|
||||
{% if endpoint.example_request %}
|
||||
<h7 class="mt-3 mb-2 text-muted">Request</h7>
|
||||
<div class="example p-3 rounded position-relative mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm position-absolute top-0 end-0 m-2 copy-btn"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="left"
|
||||
title="Copy to clipboard"
|
||||
onclick="copyToClipboard(this)">
|
||||
<i class="bi bi-clipboard" aria-hidden="true"></i>
|
||||
</button>
|
||||
<pre><code class="language-bash">{{ endpoint.example_request }}</code></pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if endpoint.example_response %}
|
||||
<h7 class="mt-3 mb-2 text-muted">Response</h7>
|
||||
<div class="example p-3 rounded position-relative mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm position-absolute top-0 end-0 m-2 copy-btn"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="left"
|
||||
title="Copy to clipboard"
|
||||
onclick="copyToClipboard(this)">
|
||||
<i class="bi bi-clipboard" aria-hidden="true"></i>
|
||||
</button>
|
||||
<pre><code class="language-json">{{ endpoint.example_response }}</code></pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if endpoint.example and not endpoint.example_request and not endpoint.example_response %}
|
||||
<div class="example p-3 rounded position-relative">
|
||||
<button class="btn btn-outline-secondary btn-sm position-absolute top-0 end-0 m-2 copy-btn"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="left"
|
||||
title="Copy to clipboard"
|
||||
onclick="copyToClipboard(this)">
|
||||
<i class="bi bi-clipboard" aria-hidden="true"></i>
|
||||
</button>
|
||||
<pre><code class="language-bash">{{ endpoint.example }}</code></pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
let isScrolling = false;
|
||||
let isNavigating = false;
|
||||
|
||||
// Check if we should disable scroll handling on mobile
|
||||
function isMobileWidth() {
|
||||
return window.innerWidth < 800;
|
||||
}
|
||||
|
||||
// Debounced scroll handler
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Function to scroll sidebar link into view if needed
|
||||
function scrollIntoViewIfNeeded(element) {
|
||||
if (!element) return;
|
||||
|
||||
const sidebar = $('.sidebar')[0];
|
||||
const rect = element.getBoundingClientRect();
|
||||
const sidebarRect = sidebar.getBoundingClientRect();
|
||||
|
||||
// Check if element is outside the sidebar viewport
|
||||
const isAboveView = rect.top < sidebarRect.top;
|
||||
const isBelowView = rect.bottom > sidebarRect.bottom;
|
||||
|
||||
if (isAboveView || isBelowView) {
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Intersection Observer for more efficient viewport detection
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '-20% 0px -70% 0px', // Trigger when element is in top 30% of viewport
|
||||
threshold: 0
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
// Don't update if user is actively navigating or on mobile
|
||||
if (isNavigating || isMobileWidth()) return;
|
||||
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const targetId = entry.target.id;
|
||||
const targetGroup = entry.target.dataset.group;
|
||||
|
||||
// Update window location hash
|
||||
if (window.location.hash !== '#' + targetId) {
|
||||
history.replaceState(null, null, '#' + targetId);
|
||||
}
|
||||
|
||||
// Remove all active states
|
||||
$('.nav-link').removeClass('active');
|
||||
$('.group-header').removeClass('active');
|
||||
|
||||
// Add active state to current item
|
||||
const $activeLink = $(`.nav-link[data-endpoint="${targetId}"]`);
|
||||
$activeLink.addClass('active');
|
||||
$(`.group-header[data-group="${targetGroup}"]`).addClass('active');
|
||||
|
||||
// Handle introduction section
|
||||
if (targetId === 'introduction') {
|
||||
const $introLink = $('a[href="#introduction"]');
|
||||
$introLink.addClass('active');
|
||||
// Scroll intro link into view in sidebar
|
||||
scrollIntoViewIfNeeded($introLink[0]);
|
||||
} else {
|
||||
// Scroll active link into view in sidebar
|
||||
if ($activeLink.length) {
|
||||
scrollIntoViewIfNeeded($activeLink[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
// Observe all endpoints and introduction (only on desktop)
|
||||
if (!isMobileWidth()) {
|
||||
$('.endpoint').each(function() {
|
||||
observer.observe(this);
|
||||
});
|
||||
|
||||
if ($('#introduction').length) {
|
||||
observer.observe($('#introduction')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth scrolling for navigation links
|
||||
$('a[href^="#"]').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
const targetHref = this.getAttribute('href');
|
||||
const target = $(targetHref);
|
||||
if (target.length) {
|
||||
// Set navigation flag to prevent observer interference
|
||||
isNavigating = true;
|
||||
|
||||
// Update window location hash immediately
|
||||
history.pushState(null, null, targetHref);
|
||||
|
||||
$('html, body').animate({
|
||||
scrollTop: target.offset().top - 20
|
||||
}, 300, function() {
|
||||
// Clear navigation flag after animation completes
|
||||
setTimeout(() => {
|
||||
isNavigating = false;
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback scroll handler with debouncing
|
||||
const handleScroll = debounce(() => {
|
||||
if (isScrolling || isNavigating || isMobileWidth()) return;
|
||||
|
||||
let current = '';
|
||||
let currentGroup = '';
|
||||
|
||||
// Check which section is currently in view
|
||||
$('.endpoint, #introduction').each(function() {
|
||||
const element = $(this);
|
||||
const elementTop = element.offset().top;
|
||||
const elementBottom = elementTop + element.outerHeight();
|
||||
const scrollTop = $(window).scrollTop() + 100; // Offset for better UX
|
||||
|
||||
if (scrollTop >= elementTop && scrollTop < elementBottom) {
|
||||
current = this.id;
|
||||
currentGroup = element.data('group');
|
||||
return false; // Break loop
|
||||
}
|
||||
});
|
||||
|
||||
if (current) {
|
||||
// Update window location hash
|
||||
if (window.location.hash !== '#' + current) {
|
||||
history.replaceState(null, null, '#' + current);
|
||||
}
|
||||
|
||||
$('.nav-link').removeClass('active');
|
||||
$('.group-header').removeClass('active');
|
||||
|
||||
const $activeLink = $(`.nav-link[data-endpoint="${current}"]`);
|
||||
$activeLink.addClass('active');
|
||||
if (currentGroup) {
|
||||
$(`.group-header[data-group="${currentGroup}"]`).addClass('active');
|
||||
}
|
||||
|
||||
if (current === 'introduction') {
|
||||
const $introLink = $('a[href="#introduction"]');
|
||||
$introLink.addClass('active');
|
||||
scrollIntoViewIfNeeded($introLink[0]);
|
||||
} else if ($activeLink.length) {
|
||||
scrollIntoViewIfNeeded($activeLink[0]);
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Only bind scroll handler on desktop
|
||||
if (!isMobileWidth()) {
|
||||
$(window).on('scroll', handleScroll);
|
||||
|
||||
// Initial call
|
||||
handleScroll();
|
||||
}
|
||||
|
||||
// Initialize tooltips
|
||||
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
const tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
});
|
||||
|
||||
// Copy to clipboard function
|
||||
function copyToClipboard(button) {
|
||||
const codeBlock = button.parentElement.querySelector('code');
|
||||
const text = codeBlock.textContent;
|
||||
|
||||
// Use modern clipboard API
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showCopyFeedback(button, true);
|
||||
}).catch(() => {
|
||||
fallbackCopyToClipboard(text, button);
|
||||
});
|
||||
} else {
|
||||
fallbackCopyToClipboard(text, button);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for older browsers
|
||||
function fallbackCopyToClipboard(text, button) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy');
|
||||
showCopyFeedback(button, successful);
|
||||
} catch (err) {
|
||||
showCopyFeedback(button, false);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
// Show copy feedback
|
||||
function showCopyFeedback(button, success) {
|
||||
const icon = button.querySelector('i');
|
||||
const originalClass = icon.className;
|
||||
const originalTitle = button.getAttribute('data-bs-original-title') || button.getAttribute('title');
|
||||
|
||||
if (success) {
|
||||
icon.className = 'bi bi-check';
|
||||
button.classList.remove('btn-outline-secondary');
|
||||
button.classList.add('btn-success');
|
||||
button.setAttribute('title', 'Copied!');
|
||||
} else {
|
||||
icon.className = 'bi bi-x';
|
||||
button.classList.remove('btn-outline-secondary');
|
||||
button.classList.add('btn-danger');
|
||||
button.setAttribute('title', 'Failed to copy');
|
||||
}
|
||||
|
||||
// Update tooltip
|
||||
const tooltip = bootstrap.Tooltip.getInstance(button);
|
||||
if (tooltip) {
|
||||
tooltip.dispose();
|
||||
new bootstrap.Tooltip(button);
|
||||
}
|
||||
|
||||
// Reset after 2 seconds
|
||||
setTimeout(() => {
|
||||
icon.className = originalClass;
|
||||
button.classList.remove('btn-success', 'btn-danger');
|
||||
button.classList.add('btn-outline-secondary');
|
||||
button.setAttribute('title', originalTitle);
|
||||
|
||||
// Update tooltip again
|
||||
const tooltip = bootstrap.Tooltip.getInstance(button);
|
||||
if (tooltip) {
|
||||
tooltip.dispose();
|
||||
new bootstrap.Tooltip(button);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user