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
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 78 79
            }
        },
    }
80
    import_remote_refs(swagger_config, reload)
81
    return swagger_config
82

Dan Jones's avatar
Dan Jones committed
83

Dan Jones's avatar
Dan Jones committed
84
def resolve_ref(ref):
85
    """
Dan Jones's avatar
Dan Jones committed
86
    Get schema URL, parse JSON
87 88 89 90 91 92
    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
93
        else:
94 95 96 97
            return None
    except (json.JSONDecodeError, ValueError):
        return None

Dan Jones's avatar
Dan Jones committed
98 99

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


127 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
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
156
def get_remote_ref_cache_path(remote_ref):
157 158 159 160
    parsed_ref = urlparse(remote_ref)
    return f"remotes/{parsed_ref.hostname}{parsed_ref.path}"


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


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

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

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


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

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

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

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


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

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

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

        return response
Dan Jones's avatar
Dan Jones committed
268

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


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


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

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

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


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

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


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

349

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

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

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

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