generate_schema_config.py 11.1 KB
Newer Older
1 2
from formats.header import message_header
from formats.message import message_schema
3
from formats.mission_plan import mission_plan_schema
4
from formats.mission_plan_encoded import mission_plan_encoded_schema
5
from formats.observation import observation_schema
6
from formats.observation_encoded import observation_encoded_schema
7
from formats.payload import payload_schema
8
from formats.planning_configuration import planning_configuration_schema
9
from formats.platform_status import platform_status_schema, waypoint_status_schema
10
from formats.platform_status_encoded import platform_status_encoded_schema
11 12
from formats.survey import survey_schema
from formats.survey_encoded import survey_encoded_schema
13
from formats.acknowledgement import acknowledgement_schema
14
from formats.alert import alert_schema
15
from formats.instruction_set import config_file_schema, instruction_set_schema
16 17 18 19

from flasgger import Swagger
from flask import Flask

20 21
import argparse
import json
22
import os
23 24
import re
import requests
25
from urllib.parse import urlparse
26

27

28 29 30 31
# Enable running on domain sub-path
URL_PREFIX = os.getenv("URL_PREFIX", "")
# Allow env override of default host
FLASK_HOST = os.getenv("FLASK_HOST", "localhost")
Dan Jones's avatar
Dan Jones committed
32 33
# Allow env override of default port
FLASK_PORT = os.getenv("FLASK_PORT", 5000)
34 35
# Switch on debug mode if env var is truthy
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "False").lower() in ("true", "1", "t")
36

37

38 39 40
def get_swagger_config(reload=False):
    if reload:
        print("Reload specified: Ignoring cached refs")
Dan Jones's avatar
Dan Jones committed
41

42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
    swagger_config = {
        "openapi": "3.0.2",
        "swagger_ui": True,
        "specs_route": "/",
        "info": {
            "title": "SoAR Backbone Message Formats",
            "version": "1.0",
            "description": "SoAR message protocol in schemas",
        },
        "specs": [
            {
                "endpoint": "swagger",
                "route": "/soar_protocol.json",
            }
        ],
        "url_prefix": URL_PREFIX,
        "paths": {},
        "components": {
            "schemas": {
61
                "MESSAGE": message_schema,
62
                "header": message_header,
63
                "payload": payload_schema,
64 65 66 67 68 69 70 71 72 73 74
                "mission_plan": mission_plan_schema,
                "mission_plan_encoded": mission_plan_encoded_schema,
                "observation": observation_schema,
                "observation_encoded": observation_encoded_schema,
                "planning_configuration": planning_configuration_schema,
                "platform_status": platform_status_schema,
                "platform_status_encoded": platform_status_encoded_schema,
                "survey": survey_schema,
                "survey_encoded": survey_encoded_schema,
                "acknowledgement": acknowledgement_schema,
                "alert": alert_schema,
75 76
                "instruction_set": instruction_set_schema,
                "config_file": config_file_schema,
77
                "waypoint_status": waypoint_status_schema,
78 79 80
            }
        },
    }
81
    import_remote_refs(swagger_config, reload)
82
    return swagger_config
83

Dan Jones's avatar
Dan Jones committed
84

Dan Jones's avatar
Dan Jones committed
85
def resolve_ref(ref):
86
    """
Dan Jones's avatar
Dan Jones committed
87
    Get schema URL, parse JSON
88 89 90 91 92 93
    Return None if either fails
    """
    try:
        res = requests.get(ref)
        if res.status_code == 200:
            return res.json()
Dan Jones's avatar
Dan Jones committed
94
        else:
95 96 97 98
            return None
    except (json.JSONDecodeError, ValueError):
        return None

Dan Jones's avatar
Dan Jones committed
99 100

def rename_ref(ref):
101
    """
Dan Jones's avatar
Dan Jones committed
102 103
    Convert remote ref URL into a name that can
    be used for a local ref in the schema
104 105 106 107 108 109 110 111 112 113
    Remote the URL scheme and replace / with .
    """
    # remove url scheme
    deschemed = re.sub(r"^[htps]*\:*[/]{2}", "", ref)
    # replace / with . since the name will be in a path
    return re.sub(r"[/]", ".", deschemed)


def nested_replace(source, key, value, replace_with):
    """
Dan Jones's avatar
Dan Jones committed
114
    Find all instances of a key value pair in a nested
115
    dictionary and replace the value with replace_with
Dan Jones's avatar
Dan Jones committed
116 117
    """
    for k, v in source.items():
118 119 120 121 122 123 124 125 126 127
        if k == key and v == value:
            source[k] = replace_with
        elif type(v) is list:
            for item in v:
                if type(item) is dict:
                    nested_replace(item, key, value, replace_with)
        if type(v) is dict:
            nested_replace(v, key, value, replace_with)


128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156
def downgrade_schema_30x_compatible(schema):
    """
    The published GeoJSON schemas are OpenAPI v3.1

    Moving to v3.1 is not trivial
    There isn't a CommonJS validator for v3.1
    There isn't a python source of the v3.0.x defs

    Remove $id and $schema
    Remove oneOf: [{type:null}]
    Iterate over oneOf and items child schemas
    """
    if "$id" in schema:
        del schema["$id"]
    if "$schema" in schema:
        del schema["$schema"]
    if "properties" in schema:
        for propConfig in schema["properties"].values():
            if "oneOf" in propConfig:
                try:
                    propConfig["oneOf"].remove({"type": "null"})
                except ValueError:
                    pass
                for child_schema in propConfig["oneOf"]:
                    downgrade_schema_30x_compatible(child_schema)
            if "items" in propConfig:
                downgrade_schema_30x_compatible(propConfig["items"])


Dan Jones's avatar
Dan Jones committed
157
def get_remote_ref_cache_path(remote_ref):
158 159 160 161
    parsed_ref = urlparse(remote_ref)
    return f"remotes/{parsed_ref.hostname}{parsed_ref.path}"


Dan Jones's avatar
Dan Jones committed
162
def get_cached_ref(remote_ref):
163 164 165 166
    ref_path = get_remote_ref_cache_path(remote_ref)
    ref = None
    if os.path.exists(ref_path):
        print(f"loading cached ref: {remote_ref}")
Dan Jones's avatar
Dan Jones committed
167
        with open(ref_path, "r") as ref_file:
168 169 170 171 172 173
            ref = json.load(ref_file)
    return ref


def store_cached_ref(remote_ref, definition):
    ref_path = get_remote_ref_cache_path(remote_ref)
Dan Jones's avatar
Dan Jones committed
174
    ref_dirs = re.sub(r"\/[^\/]+$", "", ref_path)
175
    os.makedirs(ref_dirs, 0o775, True)
Dan Jones's avatar
Dan Jones committed
176
    with open(ref_path, "w") as ref_file:
177
        json.dump(definition, ref_file, indent=2)
178 179 180


def inject_schema(schema, remote_ref, reload=False):
181 182
    """
    Given a parent schema and a remote ref
Dan Jones's avatar
Dan Jones committed
183

184 185
    1. get the remote ref schema
    2. create a local reference name (without path separators)
Dan Jones's avatar
Dan Jones committed
186 187 188
    3. insert into components.schemas
    4. replace remote references with local references

189
    returns True if resolved and injected
Dan Jones's avatar
Dan Jones committed
190
    """
191 192
    local_name = rename_ref(remote_ref)
    local_ref = f"#/components/schemas/{local_name}"
193 194 195
    # get schema from cache if present
    ref_schema = None if reload else get_cached_ref(remote_ref)
    if not ref_schema:
Dan Jones's avatar
Dan Jones committed
196
        print(f"ref not cached: {remote_ref}")
197 198 199 200
        ref_schema = resolve_ref(remote_ref)
        downgrade_schema_30x_compatible(ref_schema)
        store_cached_ref(remote_ref, ref_schema)

Dan Jones's avatar
Dan Jones committed
201
    if ref_schema is not None:
202 203 204
        nested_replace(schema, "$ref", remote_ref, local_ref)
        schema["components"]["schemas"][local_name] = ref_schema
        return True
Dan Jones's avatar
Dan Jones committed
205 206
    else:
        return False
207 208


209
def import_remote_refs(swagger_config, reload=False):
210
    """
Dan Jones's avatar
Dan Jones committed
211
    inject the following remote refs into the schema
212 213
    and replace the remote refs with local refs

Dan Jones's avatar
Dan Jones committed
214
    returns True if all schemas resolved and injected
215
    """
216
    # For some reason importing Feature or FeatureCollection
217
    # makes the schema fail to validate
218

219
    ref_imports = [
220 221
        "https://geojson.org/schema/FeatureCollection.json",
        "https://geojson.org/schema/Feature.json",
222
        "https://geojson.org/schema/LineString.json",
223 224 225
        "https://geojson.org/schema/MultiLineString.json",
        "https://geojson.org/schema/MultiPoint.json",
        "https://geojson.org/schema/MultiPolygon.json",
226 227 228 229
        "https://geojson.org/schema/Point.json",
        "https://geojson.org/schema/Polygon.json",
    ]

230
    return all([inject_schema(swagger_config, ref, reload) for ref in ref_imports])
231 232


Dan Jones's avatar
Dan Jones committed
233
def configure_flask(swagger_config):
234
    """
Dan Jones's avatar
Dan Jones committed
235
    Setup a flask app, load flasgger
236

Dan Jones's avatar
Dan Jones committed
237
    and then patch to remove invalid
238 239
    definitions:{} object
    """
240 241
    app = Flask(__name__)
    Swagger(app, config=swagger_config, merge=True)
Dan Jones's avatar
Dan Jones committed
242

243 244 245 246 247 248 249 250 251 252
    # Replace schema route to remove invalid
    # definitions: {}
    # Should be fixed if Flassger 0.9.7 is released
    #
    # The last release of flasgger was Aug 2020
    # This bug was fixed in Nov 2021
    # There is a pre-release from May 2023
    # Until the fix gets released we have to
    # remove the invalid definitions object
    # from the spec
253 254
    @app.after_request
    def after_request_decorator(response):
255
        """
Dan Jones's avatar
Dan Jones committed
256 257
        I didn't want to mess with flasgger so
        this blunt workaround that runs on every
258 259 260 261 262 263 264 265 266
        route and then checks whether it's required
        """
        is_response = type(response).__name__ == "Response"
        is_json = is_response and response.content_type == "application/json"
        if is_json:
            parsed = response.json
            if "definitions" in parsed:
                del parsed["definitions"]
            response.data = json.dumps(parsed)
267 268

        return response
Dan Jones's avatar
Dan Jones committed
269

Dan Jones's avatar
Dan Jones committed
270 271 272 273 274
    return app


def serve(swagger_config):
    """
275
    Run as local flask app on FLASK_PORT|5000
Dan Jones's avatar
Dan Jones committed
276
    """
Dan Jones's avatar
Dan Jones committed
277
    app = configure_flask(swagger_config)
278
    app.run(debug=FLASK_DEBUG, host=FLASK_HOST, port=FLASK_PORT)
279 280


281 282
def compile_schema(swagger_config):
    """Extract the output schema from flasgger
Dan Jones's avatar
Dan Jones committed
283 284 285

    The only way I have found to do this is to
    use a test client to make the GET request
286 287
    for the page

Dan Jones's avatar
Dan Jones committed
288
    The function that returns the definition
289 290
    can't be called outside the flask app context
    """
Dan Jones's avatar
Dan Jones committed
291 292
    app = configure_flask(swagger_config)
    route = swagger_config["specs"][0]["route"]
293 294 295 296 297 298
    client = app.test_client()
    response = client.get(route)
    spec = response.json
    return spec


299 300 301 302
def write_schema(swagger_config, file_path):
    """
    Dump schema to specified file
    """
303 304 305
    spec = compile_schema(swagger_config)
    json_schema = json.dumps(spec, indent=2)

Dan Jones's avatar
Dan Jones committed
306
    with open(file_path, "w") as f:
307 308 309 310 311 312 313
        f.write(json_schema)


def get_options():
    """
    Parse script arguments
    """
Dan Jones's avatar
Dan Jones committed
314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333
    parser = argparse.ArgumentParser(
        description="Generate the schema",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "-s",
        "--serve",
        dest="run_flask",
        action="store_true",
        help="Run flask app",
        default=False,
    )
    parser.add_argument(
        "-f",
        "--file",
        dest="output_file",
        action="store_true",
        help="Save output to schema file",
        default=False,
    )
334 335 336 337 338 339 340 341
    parser.add_argument(
        "-r",
        "--reload",
        dest="reload_schemas",
        action="store_true",
        help="Overwrite local copies of remote reference schemas",
        default=False,
    )
342
    parser.add_argument("filename", nargs="?", default="project/soar/swagger.json")
343 344 345
    args = parser.parse_args()
    config = vars(args)
    # If no flag is specified default to running the flask server
346
    if not (config["run_flask"] or config["output_file"]):
347
        config["run_flask"] = True
Dan Jones's avatar
Dan Jones committed
348 349
    return config

350

Dan Jones's avatar
Dan Jones committed
351 352 353
if __name__ == "__main__":
    # Parse script args
    config = get_options()
354

Dan Jones's avatar
Dan Jones committed
355
    swagger_config = get_swagger_config(config.get("reload_schemas"))
356

357 358
    # Output compiled schema
    if config.get("output_file"):
359
        write_schema(swagger_config, config.get("filename"))
360

361 362
    # Run flask app
    if config.get("run_flask"):
Dan Jones's avatar
Dan Jones committed
363
        serve(swagger_config)