generate_schema_config.py 11.2 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 10
from formats.platform_status import platform_status_schema
from formats.waypoint_status import waypoint_status_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
from formats.instruction_set import config_file_schema, instruction_set_schema
17 18 19 20

from flasgger import Swagger
from flask import Flask

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

28

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

38

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

43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
    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": {
62
                "MESSAGE": message_schema,
63
                "header": message_header,
64
                "payload": payload_schema,
65 66 67 68 69 70 71 72 73 74 75
                "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,
76 77
                "instruction_set": instruction_set_schema,
                "config_file": config_file_schema,
78
                "waypoint_status": waypoint_status_schema,
79 80 81
            }
        },
    }
82
    import_remote_refs(swagger_config, reload)
83
    return swagger_config
84

Dan Jones's avatar
Dan Jones committed
85

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

Dan Jones's avatar
Dan Jones committed
100 101

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


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


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


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

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

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


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

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

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

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


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

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

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

        return response
Dan Jones's avatar
Dan Jones committed
270

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


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


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

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

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


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

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


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

351

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

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

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

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