generate_schema_config.py 13.4 KB
Newer Older
1 2
from formats import message_header
from formats.mission_plan import mission_plan_schema
3
from formats.mission_plan_encoded import mission_plan_encoded_schema
4
from formats.observation import observation_schema
5
from formats.observation_encoded import observation_encoded_schema
6
from formats.planning_configuration import planning_configuration_schema
7
from formats.platform_status import platform_status_schema
8
from formats.platform_status_encoded import platform_status_encoded_schema
9 10
from formats.survey import survey_schema
from formats.survey_encoded import survey_encoded_schema
11
from formats.acknowledgement import acknowledgement_schema
12
from formats.alert import alert_schema
13 14 15 16

from flasgger import Swagger
from flask import Flask

17 18
import argparse
import json
19
import os
20 21
import re
import requests
22
from urllib.parse import urlparse
23

24

25 26 27 28
# 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
29 30
# Allow env override of default port
FLASK_PORT = os.getenv("FLASK_PORT", 5000)
31 32
# Switch on debug mode if env var is truthy
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "False").lower() in ("true", "1", "t")
33

34

35 36 37
def get_swagger_config(reload=False):
    if reload:
        print("Reload specified: Ignoring cached refs")
Dan Jones's avatar
Dan Jones committed
38

39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
    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": {
                "MESSAGE": {
                    "type": "object",
                    "description": "Full message definition with"
                    + " message-metadata in `header` and different"
                    + " message type schemas under `payload`",
                    "properties": {
                        "header": {
                            "$ref": "#/components/schemas/header",
                        },
                        "payload": {"$ref": "#/components/schemas/payload"},
68
                    },
69
                    "required": ["header", "payload"],
70
                },
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88
                "payload": {
                    "discriminator": {
                        "propertyName": "message_type",
                        "mapping": {
                            "alert": "#/components/schemas/alert",
                            "mission_plan": "#/components/schemas/mission_plan",
                            "mission_plan_encoded": "#/components/schemas/"
                            + "mission_plan_encoded",
                            "observation": "#/components/schemas/observation",
                            "observation_encoded": "#/components/schemas/"
                            + "observation_encoded",
                            "planning_configuration": "#/components/schemas/"
                            + "planning_configuration",
                            "platform_status": "#/components/schemas/platform_status",
                            "platform_status_encoded": "#/components/schemas/"
                            + "platform_status_encoded",
                            "acknowledgement": "#/components/schemas/acknowledgement",
                            "survey": "#/components/schemas/survey",
89 90
                            "survey_encoded": "#/components/schemas/"
                            + "survey_encoded",
91
                        },
92
                    },
93 94 95 96 97 98 99 100 101 102 103 104 105
                    "oneOf": [
                        {"$ref": "#/components/schemas/alert"},
                        {"$ref": "#/components/schemas/acknowledgement"},
                        {"$ref": "#/components/schemas/mission_plan"},
                        {"$ref": "#/components/schemas/mission_plan_encoded"},
                        {"$ref": "#/components/schemas/observation"},
                        {"$ref": "#/components/schemas/observation_encoded"},
                        {"$ref": "#/components/schemas/planning_configuration"},
                        {"$ref": "#/components/schemas/platform_status"},
                        {"$ref": "#/components/schemas/platform_status_encoded"},
                        {"$ref": "#/components/schemas/survey"},
                        {"$ref": "#/components/schemas/survey_encoded"},
                    ],
106
                },
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121
                "header": message_header,
                "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,
            }
        },
    }
122
    import_remote_refs(swagger_config, reload)
123
    return swagger_config
124

Dan Jones's avatar
Dan Jones committed
125

Dan Jones's avatar
Dan Jones committed
126
def resolve_ref(ref):
127
    """
Dan Jones's avatar
Dan Jones committed
128
    Get schema URL, parse JSON
129 130 131 132 133 134
    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
135
        else:
136 137 138 139
            return None
    except (json.JSONDecodeError, ValueError):
        return None

Dan Jones's avatar
Dan Jones committed
140 141

def rename_ref(ref):
142
    """
Dan Jones's avatar
Dan Jones committed
143 144
    Convert remote ref URL into a name that can
    be used for a local ref in the schema
145 146 147 148 149 150 151 152 153 154
    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
155
    Find all instances of a key value pair in a nested
156
    dictionary and replace the value with replace_with
Dan Jones's avatar
Dan Jones committed
157 158
    """
    for k, v in source.items():
159 160 161 162 163 164 165 166 167 168
        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)


169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
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
198
def get_remote_ref_cache_path(remote_ref):
199 200 201 202
    parsed_ref = urlparse(remote_ref)
    return f"remotes/{parsed_ref.hostname}{parsed_ref.path}"


Dan Jones's avatar
Dan Jones committed
203
def get_cached_ref(remote_ref):
204 205 206 207
    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
208
        with open(ref_path, "r") as ref_file:
209 210 211 212 213 214
            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
215
    ref_dirs = re.sub(r"\/[^\/]+$", "", ref_path)
216
    os.makedirs(ref_dirs, 0o775, True)
Dan Jones's avatar
Dan Jones committed
217
    with open(ref_path, "w") as ref_file:
218
        json.dump(definition, ref_file, indent=2)
219 220 221


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

225 226
    1. get the remote ref schema
    2. create a local reference name (without path separators)
Dan Jones's avatar
Dan Jones committed
227 228 229
    3. insert into components.schemas
    4. replace remote references with local references

230
    returns True if resolved and injected
Dan Jones's avatar
Dan Jones committed
231
    """
232 233
    local_name = rename_ref(remote_ref)
    local_ref = f"#/components/schemas/{local_name}"
234 235 236
    # 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
237
        print(f"ref not cached: {remote_ref}")
238 239 240 241
        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
242
    if ref_schema is not None:
243 244 245
        nested_replace(schema, "$ref", remote_ref, local_ref)
        schema["components"]["schemas"][local_name] = ref_schema
        return True
Dan Jones's avatar
Dan Jones committed
246 247
    else:
        return False
248 249


250
def import_remote_refs(swagger_config, reload=False):
251
    """
Dan Jones's avatar
Dan Jones committed
252
    inject the following remote refs into the schema
253 254
    and replace the remote refs with local refs

Dan Jones's avatar
Dan Jones committed
255
    returns True if all schemas resolved and injected
256
    """
257
    # For some reason importing Feature or FeatureCollection
258
    # makes the schema fail to validate
259

260
    ref_imports = [
261 262
        "https://geojson.org/schema/FeatureCollection.json",
        "https://geojson.org/schema/Feature.json",
263
        "https://geojson.org/schema/LineString.json",
264 265 266
        "https://geojson.org/schema/MultiLineString.json",
        "https://geojson.org/schema/MultiPoint.json",
        "https://geojson.org/schema/MultiPolygon.json",
267 268 269 270
        "https://geojson.org/schema/Point.json",
        "https://geojson.org/schema/Polygon.json",
    ]

271
    return all([inject_schema(swagger_config, ref, reload) for ref in ref_imports])
272 273


Dan Jones's avatar
Dan Jones committed
274
def configure_flask(swagger_config):
275
    """
Dan Jones's avatar
Dan Jones committed
276
    Setup a flask app, load flasgger
277

Dan Jones's avatar
Dan Jones committed
278
    and then patch to remove invalid
279 280
    definitions:{} object
    """
281 282
    app = Flask(__name__)
    Swagger(app, config=swagger_config, merge=True)
Dan Jones's avatar
Dan Jones committed
283

284 285 286 287 288 289 290 291 292 293
    # 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
294 295
    @app.after_request
    def after_request_decorator(response):
296
        """
Dan Jones's avatar
Dan Jones committed
297 298
        I didn't want to mess with flasgger so
        this blunt workaround that runs on every
299 300 301 302 303 304 305 306 307
        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)
308 309

        return response
Dan Jones's avatar
Dan Jones committed
310

Dan Jones's avatar
Dan Jones committed
311 312 313 314 315
    return app


def serve(swagger_config):
    """
316
    Run as local flask app on FLASK_PORT|5000
Dan Jones's avatar
Dan Jones committed
317
    """
Dan Jones's avatar
Dan Jones committed
318
    app = configure_flask(swagger_config)
319
    app.run(debug=FLASK_DEBUG, host=FLASK_HOST, port=FLASK_PORT)
320 321


322 323
def compile_schema(swagger_config):
    """Extract the output schema from flasgger
Dan Jones's avatar
Dan Jones committed
324 325 326

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

Dan Jones's avatar
Dan Jones committed
329
    The function that returns the definition
330 331
    can't be called outside the flask app context
    """
Dan Jones's avatar
Dan Jones committed
332 333
    app = configure_flask(swagger_config)
    route = swagger_config["specs"][0]["route"]
334 335 336 337 338 339
    client = app.test_client()
    response = client.get(route)
    spec = response.json
    return spec


340 341 342 343
def write_schema(swagger_config, file_path):
    """
    Dump schema to specified file
    """
344 345 346
    spec = compile_schema(swagger_config)
    json_schema = json.dumps(spec, indent=2)

Dan Jones's avatar
Dan Jones committed
347
    with open(file_path, "w") as f:
348 349 350 351 352 353 354
        f.write(json_schema)


def get_options():
    """
    Parse script arguments
    """
Dan Jones's avatar
Dan Jones committed
355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374
    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,
    )
375 376 377 378 379 380 381 382
    parser.add_argument(
        "-r",
        "--reload",
        dest="reload_schemas",
        action="store_true",
        help="Overwrite local copies of remote reference schemas",
        default=False,
    )
383
    parser.add_argument("filename", nargs="?", default="project/soar/swagger.json")
384 385 386
    args = parser.parse_args()
    config = vars(args)
    # If no flag is specified default to running the flask server
387
    if not (config["run_flask"] or config["output_file"]):
388
        config["run_flask"] = True
Dan Jones's avatar
Dan Jones committed
389 390
    return config

391

Dan Jones's avatar
Dan Jones committed
392 393 394
if __name__ == "__main__":
    # Parse script args
    config = get_options()
395

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

398 399
    # Output compiled schema
    if config.get("output_file"):
400
        write_schema(swagger_config, config.get("filename"))
401

402 403
    # Run flask app
    if config.get("run_flask"):
Dan Jones's avatar
Dan Jones committed
404
        serve(swagger_config)