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

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
swagger_config = {
    "openapi": "3.0.2",
    "swagger_ui": True,
    "specs_route": "/",
37
    "info": {
38 39
        "title": "SoAR Backbone Message Formats",
        "version": "1.0",
40
        "description": "SoAR message protocol in schemas",
41 42 43 44 45 46 47
    },
    "specs": [
        {
            "endpoint": "swagger",
            "route": "/soar_protocol.json",
        }
    ],
48
    "url_prefix": URL_PREFIX,
49 50 51 52 53
    "paths": {},
    "components": {
        "schemas": {
            "MESSAGE": {
                "type": "object",
Trishna Saeharaseelan's avatar
Trishna Saeharaseelan committed
54 55 56
                "description": "Full message definition with"
                + " message-metadata in `header` and different"
                + " message type schemas under `payload`",
57 58 59 60
                "properties": {
                    "header": {
                        "$ref": "#/components/schemas/header",
                    },
61
                    "payload": {"$ref": "#/components/schemas/payload"},
62 63 64 65 66 67
                },
                "required": ["header", "payload"],
            },
            "payload": {
                "discriminator": {
                    "propertyName": "message_type",
68
                    "mapping": {
69
                        "alert": "#/components/schemas/alert",
70
                        "mission_plan": "#/components/schemas/mission_plan",
71 72
                        "mission_plan_encoded": "#/components/schemas/"
                        + "mission_plan_encoded",
73
                        "observation": "#/components/schemas/observation",
74 75
                        "observation_encoded": "#/components/schemas/"
                        + "observation_encoded",
Trishna Saeharaseelan's avatar
Trishna Saeharaseelan committed
76 77
                        "planning_configuration": "#/components/schemas/"
                        + "planning_configuration",
78
                        "platform_status": "#/components/schemas/platform_status",
79 80
                        "platform_status_encoded": "#/components/schemas/"
                        + "platform_status_encoded",
81
                        "acknowledgement": "#/components/schemas/acknowledgement",
82
                        "survey": "#/components/schemas/survey",
Trishna Saeharaseelan's avatar
Trishna Saeharaseelan committed
83
                        "survey_encoded": "#/components/schemas/" + "survey_encoded",
84 85
                    },
                },
86
                "oneOf": [
87
                    {"$ref": "#/components/schemas/alert"},
Trishna Saeharaseelan's avatar
Trishna Saeharaseelan committed
88 89
                    {"$ref": "#/components/schemas/acknowledgement"},
                    {"$ref": "#/components/schemas/mission_plan"},
90
                    {"$ref": "#/components/schemas/mission_plan_encoded"},
Trishna Saeharaseelan's avatar
Trishna Saeharaseelan committed
91
                    {"$ref": "#/components/schemas/observation"},
92
                    {"$ref": "#/components/schemas/observation_encoded"},
Trishna Saeharaseelan's avatar
Trishna Saeharaseelan committed
93 94
                    {"$ref": "#/components/schemas/planning_configuration"},
                    {"$ref": "#/components/schemas/platform_status"},
95
                    {"$ref": "#/components/schemas/platform_status_encoded"},
96 97
                    {"$ref": "#/components/schemas/survey"},
                    {"$ref": "#/components/schemas/survey_encoded"},
98
                ],
99 100 101
            },
            "header": message_header,
            "mission_plan": mission_plan_schema,
102
            "mission_plan_encoded": mission_plan_encoded_schema,
103
            "observation": observation_schema,
104
            "observation_encoded": observation_encoded_schema,
105
            "planning_configuration": planning_configuration_schema,
106
            "platform_status": platform_status_schema,
107
            "platform_status_encoded": platform_status_encoded_schema,
108 109
            "survey": survey_schema,
            "survey_encoded": survey_encoded_schema,
110
            "acknowledgement": acknowledgement_schema,
111
            "alert": alert_schema,
112 113 114 115
        }
    },
}

Dan Jones's avatar
Dan Jones committed
116

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

Dan Jones's avatar
Dan Jones committed
131 132

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

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

169 170

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


def import_remote_refs():
    """
Dan Jones's avatar
Dan Jones committed
185
    inject the following remote refs into the schema
186 187
    and replace the remote refs with local refs

Dan Jones's avatar
Dan Jones committed
188
    returns True if all schemas resolved and injected
189 190 191 192 193 194 195 196 197
    """
    ref_imports = [
        "https://geojson.org/schema/Feature.json",
        "https://geojson.org/schema/FeatureCollection.json",
        "https://geojson.org/schema/LineString.json",
        "https://geojson.org/schema/Point.json",
        "https://geojson.org/schema/Polygon.json",
    ]

Dan Jones's avatar
Dan Jones committed
198
    return all([inject_schema(swagger_config, ref) for ref in ref_imports])
199 200


Dan Jones's avatar
Dan Jones committed
201
def configure_flask(swagger_config):
202
    """
Dan Jones's avatar
Dan Jones committed
203
    Setup a flask app, load flasgger
204

Dan Jones's avatar
Dan Jones committed
205
    and then patch to remove invalid
206 207
    definitions:{} object
    """
208 209
    app = Flask(__name__)
    Swagger(app, config=swagger_config, merge=True)
Dan Jones's avatar
Dan Jones committed
210

211 212 213 214 215 216 217 218 219 220
    # 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
221 222
    @app.after_request
    def after_request_decorator(response):
223
        """
Dan Jones's avatar
Dan Jones committed
224 225
        I didn't want to mess with flasgger so
        this blunt workaround that runs on every
226 227 228 229 230 231 232 233 234
        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)
235 236

        return response
Dan Jones's avatar
Dan Jones committed
237

Dan Jones's avatar
Dan Jones committed
238 239 240 241 242
    return app


def serve(swagger_config):
    """
243
    Run as local flask app on FLASK_PORT|5000
Dan Jones's avatar
Dan Jones committed
244
    """
Dan Jones's avatar
Dan Jones committed
245
    app = configure_flask(swagger_config)
246
    app.run(debug=FLASK_DEBUG, host=FLASK_HOST, port=FLASK_PORT)
247 248


249 250
def compile_schema(swagger_config):
    """Extract the output schema from flasgger
Dan Jones's avatar
Dan Jones committed
251 252 253

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

Dan Jones's avatar
Dan Jones committed
256
    The function that returns the definition
257 258
    can't be called outside the flask app context
    """
Dan Jones's avatar
Dan Jones committed
259 260
    app = configure_flask(swagger_config)
    route = swagger_config["specs"][0]["route"]
261 262 263 264 265 266
    client = app.test_client()
    response = client.get(route)
    spec = response.json
    return spec


267 268 269 270
def write_schema(swagger_config, file_path):
    """
    Dump schema to specified file
    """
271 272 273
    spec = compile_schema(swagger_config)
    json_schema = json.dumps(spec, indent=2)

Dan Jones's avatar
Dan Jones committed
274
    with open(file_path, "w") as f:
275 276 277 278 279 280 281
        f.write(json_schema)


def get_options():
    """
    Parse script arguments
    """
Dan Jones's avatar
Dan Jones committed
282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301
    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,
    )
302
    parser.add_argument("filename", nargs="?", default="project/soar/swagger.json")
303 304 305
    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
306
    if all(v is False for v in config.values()):
307
        config["run_flask"] = True
Dan Jones's avatar
Dan Jones committed
308 309
    return config

310

Dan Jones's avatar
Dan Jones committed
311
if __name__ == "__main__":
312 313
    import_remote_refs()
    
Dan Jones's avatar
Dan Jones committed
314 315
    # Parse script args
    config = get_options()
316

317 318
    # Output compiled schema
    if config.get("output_file"):
319
        write_schema(swagger_config, config.get("filename"))
320

321 322
    # Run flask app
    if config.get("run_flask"):
Dan Jones's avatar
Dan Jones committed
323
        serve(swagger_config)