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 85 86
                "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",
                            "survey_encoded": "#/components/schemas/" + "survey_encoded",
                        },
87
                    },
88 89 90 91 92 93 94 95 96 97 98 99 100
                    "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"},
                    ],
101
                },
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118
                "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
119

Dan Jones's avatar
Dan Jones committed
120

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

Dan Jones's avatar
Dan Jones committed
135 136

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

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

173 174

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


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

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

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

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


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

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

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

        return response
Dan Jones's avatar
Dan Jones committed
249

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


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


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

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

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


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

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


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

322

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

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

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

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