generate_schema_config.py 11.3 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
Dan Jones's avatar
Dan Jones committed
10
from formats.waypoints import waypoints_schema
11
from formats.platform_status_encoded import platform_status_encoded_schema
12 13
from formats.survey import survey_schema
from formats.survey_encoded import survey_encoded_schema
14
from formats.acknowledgement import acknowledgement_schema
15
from formats.alert import alert_schema
16 17 18 19 20
from formats.platform_instruction_set import (
    config_file_schema,
    instruction_set_schema,
    platform_instruction_set_schema,
)
21 22 23 24

from flasgger import Swagger
from flask import Flask

25 26
import argparse
import json
27
import os
28 29
import re
import requests
30
from urllib.parse import urlparse
31

32

33 34 35 36
# 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
37 38
# Allow env override of default port
FLASK_PORT = os.getenv("FLASK_PORT", 5000)
39 40
# Switch on debug mode if env var is truthy
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "False").lower() in ("true", "1", "t")
41

42

43 44 45
def get_swagger_config(reload=False):
    if reload:
        print("Reload specified: Ignoring cached refs")
Dan Jones's avatar
Dan Jones committed
46

47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
    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": {
66
                "MESSAGE": message_schema,
67
                "header": message_header,
68
                "payload": payload_schema,
69 70 71 72 73
                "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,
74
                "platform_instruction_set": platform_instruction_set_schema,
75 76 77 78 79 80
                "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,
81 82
                "instruction_set": instruction_set_schema,
                "config_file": config_file_schema,
Dan Jones's avatar
Dan Jones committed
83
                "waypoints": waypoints_schema,
84 85 86
            }
        },
    }
87
    import_remote_refs(swagger_config, reload)
88
    return swagger_config
89

Dan Jones's avatar
Dan Jones committed
90

Dan Jones's avatar
Dan Jones committed
91
def resolve_ref(ref):
92
    """
Dan Jones's avatar
Dan Jones committed
93
    Get schema URL, parse JSON
94 95 96 97 98 99
    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
100
        else:
101 102 103 104
            return None
    except (json.JSONDecodeError, ValueError):
        return None

Dan Jones's avatar
Dan Jones committed
105 106

def rename_ref(ref):
107
    """
Dan Jones's avatar
Dan Jones committed
108 109
    Convert remote ref URL into a name that can
    be used for a local ref in the schema
110 111 112 113 114 115 116 117 118 119
    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
120
    Find all instances of a key value pair in a nested
121
    dictionary and replace the value with replace_with
Dan Jones's avatar
Dan Jones committed
122 123
    """
    for k, v in source.items():
124 125 126 127 128 129 130 131 132 133
        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)


134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162
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
163
def get_remote_ref_cache_path(remote_ref):
164 165 166 167
    parsed_ref = urlparse(remote_ref)
    return f"remotes/{parsed_ref.hostname}{parsed_ref.path}"


Dan Jones's avatar
Dan Jones committed
168
def get_cached_ref(remote_ref):
169 170 171 172
    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
173
        with open(ref_path, "r") as ref_file:
174 175 176 177 178 179
            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
180
    ref_dirs = re.sub(r"\/[^\/]+$", "", ref_path)
181
    os.makedirs(ref_dirs, 0o775, True)
Dan Jones's avatar
Dan Jones committed
182
    with open(ref_path, "w") as ref_file:
183
        json.dump(definition, ref_file, indent=2)
184 185 186


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

190 191
    1. get the remote ref schema
    2. create a local reference name (without path separators)
Dan Jones's avatar
Dan Jones committed
192 193 194
    3. insert into components.schemas
    4. replace remote references with local references

195
    returns True if resolved and injected
Dan Jones's avatar
Dan Jones committed
196
    """
197 198
    local_name = rename_ref(remote_ref)
    local_ref = f"#/components/schemas/{local_name}"
199 200 201
    # 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
202
        print(f"ref not cached: {remote_ref}")
203 204 205 206
        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
207
    if ref_schema is not None:
208 209 210
        nested_replace(schema, "$ref", remote_ref, local_ref)
        schema["components"]["schemas"][local_name] = ref_schema
        return True
Dan Jones's avatar
Dan Jones committed
211 212
    else:
        return False
213 214


215
def import_remote_refs(swagger_config, reload=False):
216
    """
Dan Jones's avatar
Dan Jones committed
217
    inject the following remote refs into the schema
218 219
    and replace the remote refs with local refs

Dan Jones's avatar
Dan Jones committed
220
    returns True if all schemas resolved and injected
221
    """
222
    # For some reason importing Feature or FeatureCollection
223
    # makes the schema fail to validate
224

225
    ref_imports = [
226 227
        "https://geojson.org/schema/FeatureCollection.json",
        "https://geojson.org/schema/Feature.json",
228
        "https://geojson.org/schema/LineString.json",
229 230 231
        "https://geojson.org/schema/MultiLineString.json",
        "https://geojson.org/schema/MultiPoint.json",
        "https://geojson.org/schema/MultiPolygon.json",
232 233 234 235
        "https://geojson.org/schema/Point.json",
        "https://geojson.org/schema/Polygon.json",
    ]

236
    return all([inject_schema(swagger_config, ref, reload) for ref in ref_imports])
237 238


Dan Jones's avatar
Dan Jones committed
239
def configure_flask(swagger_config):
240
    """
Dan Jones's avatar
Dan Jones committed
241
    Setup a flask app, load flasgger
242

Dan Jones's avatar
Dan Jones committed
243
    and then patch to remove invalid
244 245
    definitions:{} object
    """
246 247
    app = Flask(__name__)
    Swagger(app, config=swagger_config, merge=True)
Dan Jones's avatar
Dan Jones committed
248

249 250 251 252 253 254 255 256 257 258
    # 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
259 260
    @app.after_request
    def after_request_decorator(response):
261
        """
Dan Jones's avatar
Dan Jones committed
262 263
        I didn't want to mess with flasgger so
        this blunt workaround that runs on every
264 265 266 267 268 269 270 271 272
        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)
273 274

        return response
Dan Jones's avatar
Dan Jones committed
275

Dan Jones's avatar
Dan Jones committed
276 277 278 279 280
    return app


def serve(swagger_config):
    """
281
    Run as local flask app on FLASK_PORT|5000
Dan Jones's avatar
Dan Jones committed
282
    """
Dan Jones's avatar
Dan Jones committed
283
    app = configure_flask(swagger_config)
284
    app.run(debug=FLASK_DEBUG, host=FLASK_HOST, port=FLASK_PORT)
285 286


287 288
def compile_schema(swagger_config):
    """Extract the output schema from flasgger
Dan Jones's avatar
Dan Jones committed
289 290 291

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

Dan Jones's avatar
Dan Jones committed
294
    The function that returns the definition
295 296
    can't be called outside the flask app context
    """
Dan Jones's avatar
Dan Jones committed
297 298
    app = configure_flask(swagger_config)
    route = swagger_config["specs"][0]["route"]
299 300 301 302 303 304
    client = app.test_client()
    response = client.get(route)
    spec = response.json
    return spec


305 306 307 308
def write_schema(swagger_config, file_path):
    """
    Dump schema to specified file
    """
309 310 311
    spec = compile_schema(swagger_config)
    json_schema = json.dumps(spec, indent=2)

Dan Jones's avatar
Dan Jones committed
312
    with open(file_path, "w") as f:
313 314 315 316 317 318 319
        f.write(json_schema)


def get_options():
    """
    Parse script arguments
    """
Dan Jones's avatar
Dan Jones committed
320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339
    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,
    )
340 341 342 343 344 345 346 347
    parser.add_argument(
        "-r",
        "--reload",
        dest="reload_schemas",
        action="store_true",
        help="Overwrite local copies of remote reference schemas",
        default=False,
    )
348
    parser.add_argument("filename", nargs="?", default="project/soar/swagger.json")
349 350 351
    args = parser.parse_args()
    config = vars(args)
    # If no flag is specified default to running the flask server
352
    if not (config["run_flask"] or config["output_file"]):
353
        config["run_flask"] = True
Dan Jones's avatar
Dan Jones committed
354 355
    return config

356

Dan Jones's avatar
Dan Jones committed
357 358 359
if __name__ == "__main__":
    # Parse script args
    config = get_options()
360

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

363 364
    # Output compiled schema
    if config.get("output_file"):
365
        write_schema(swagger_config, config.get("filename"))
366

367 368
    # Run flask app
    if config.get("run_flask"):
Dan Jones's avatar
Dan Jones committed
369
        serve(swagger_config)