generate_schema_config.py 11.2 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

23

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

33 34 35 36 37 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

def get_swagger_config():
    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"},
64
                    },
65
                    "required": ["header", "payload"],
66
                },
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84
                "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",
85 86
                            "survey_encoded": "#/components/schemas/"
                            + "survey_encoded",
87
                        },
88
                    },
89 90 91 92 93 94 95 96 97 98 99 100 101
                    "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"},
                    ],
102
                },
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
                "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,
            }
        },
    }
    import_remote_refs(swagger_config)
    return swagger_config
120

Dan Jones's avatar
Dan Jones committed
121

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

Dan Jones's avatar
Dan Jones committed
136 137

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


def inject_schema(schema, remote_ref):
    """
    Given a parent schema and a remote ref
Dan Jones's avatar
Dan Jones committed
168

169 170
    1. get the remote ref schema
    2. create a local reference name (without path separators)
Dan Jones's avatar
Dan Jones committed
171 172 173
    3. insert into components.schemas
    4. replace remote references with local references

174 175

    returns True if resolved and injected
Dan Jones's avatar
Dan Jones committed
176
    """
177 178 179
    local_name = rename_ref(remote_ref)
    local_ref = f"#/components/schemas/{local_name}"
    ref_schema = resolve_ref(remote_ref)
180 181
    del ref_schema["$id"]
    del ref_schema["$schema"]
Dan Jones's avatar
Dan Jones committed
182
    if ref_schema is not None:
183 184 185
        nested_replace(schema, "$ref", remote_ref, local_ref)
        schema["components"]["schemas"][local_name] = ref_schema
        return True
Dan Jones's avatar
Dan Jones committed
186 187
    else:
        return False
188 189


190
def import_remote_refs(swagger_config):
191
    """
Dan Jones's avatar
Dan Jones committed
192
    inject the following remote refs into the schema
193 194
    and replace the remote refs with local refs

Dan Jones's avatar
Dan Jones committed
195
    returns True if all schemas resolved and injected
196
    """
197
    # For some reason importing Feature or FeatureCollection
198
    # makes the schema fail to validate
199

200
    ref_imports = [
201 202
        # "https://geojson.org/schema/Feature.json",
        # "https://geojson.org/schema/FeatureCollection.json",
203
        "https://geojson.org/schema/LineString.json",
204 205 206
        "https://geojson.org/schema/MultiLineString.json",
        "https://geojson.org/schema/MultiPoint.json",
        "https://geojson.org/schema/MultiPolygon.json",
207 208 209 210
        "https://geojson.org/schema/Point.json",
        "https://geojson.org/schema/Polygon.json",
    ]

Dan Jones's avatar
Dan Jones committed
211
    return all([inject_schema(swagger_config, ref) for ref in ref_imports])
212 213


Dan Jones's avatar
Dan Jones committed
214
def configure_flask(swagger_config):
215
    """
Dan Jones's avatar
Dan Jones committed
216
    Setup a flask app, load flasgger
217

Dan Jones's avatar
Dan Jones committed
218
    and then patch to remove invalid
219 220
    definitions:{} object
    """
221 222
    app = Flask(__name__)
    Swagger(app, config=swagger_config, merge=True)
Dan Jones's avatar
Dan Jones committed
223

224 225 226 227 228 229 230 231 232 233
    # 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
234 235
    @app.after_request
    def after_request_decorator(response):
236
        """
Dan Jones's avatar
Dan Jones committed
237 238
        I didn't want to mess with flasgger so
        this blunt workaround that runs on every
239 240 241 242 243 244 245 246 247
        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)
248 249

        return response
Dan Jones's avatar
Dan Jones committed
250

Dan Jones's avatar
Dan Jones committed
251 252 253 254 255
    return app


def serve(swagger_config):
    """
256
    Run as local flask app on FLASK_PORT|5000
Dan Jones's avatar
Dan Jones committed
257
    """
Dan Jones's avatar
Dan Jones committed
258
    app = configure_flask(swagger_config)
259
    app.run(debug=FLASK_DEBUG, host=FLASK_HOST, port=FLASK_PORT)
260 261


262 263
def compile_schema(swagger_config):
    """Extract the output schema from flasgger
Dan Jones's avatar
Dan Jones committed
264 265 266

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

Dan Jones's avatar
Dan Jones committed
269
    The function that returns the definition
270 271
    can't be called outside the flask app context
    """
Dan Jones's avatar
Dan Jones committed
272 273
    app = configure_flask(swagger_config)
    route = swagger_config["specs"][0]["route"]
274 275 276 277 278 279
    client = app.test_client()
    response = client.get(route)
    spec = response.json
    return spec


280 281 282 283
def write_schema(swagger_config, file_path):
    """
    Dump schema to specified file
    """
284 285 286
    spec = compile_schema(swagger_config)
    json_schema = json.dumps(spec, indent=2)

Dan Jones's avatar
Dan Jones committed
287
    with open(file_path, "w") as f:
288 289 290 291 292 293 294
        f.write(json_schema)


def get_options():
    """
    Parse script arguments
    """
Dan Jones's avatar
Dan Jones committed
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314
    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,
    )
315
    parser.add_argument("filename", nargs="?", default="project/soar/swagger.json")
316 317 318
    args = parser.parse_args()
    config = vars(args)
    # If no flag is specified default to running the flask server
319
    if not (config['run_flask'] or config['output_file']):
320
        config["run_flask"] = True
Dan Jones's avatar
Dan Jones committed
321 322
    return config

323

Dan Jones's avatar
Dan Jones committed
324
if __name__ == "__main__":
325
    swagger_config = get_swagger_config()
326

Dan Jones's avatar
Dan Jones committed
327 328
    # Parse script args
    config = get_options()
329

330 331
    # Output compiled schema
    if config.get("output_file"):
332
        write_schema(swagger_config, config.get("filename"))
333

334 335
    # Run flask app
    if config.get("run_flask"):
Dan Jones's avatar
Dan Jones committed
336
        serve(swagger_config)