Add OpenApi definition and docs (#617)

Fixes #608.
This commit is contained in:
Boris Feld 2019-07-18 11:42:27 +02:00 коммит произвёл Marco
Родитель d908101d4d
Коммит d00c175d66
5 изменённых файлов: 260 добавлений и 8 удалений

Просмотреть файл

@ -4,4 +4,4 @@ include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=88
known_third_party = dateutil,flask,hglib,imblearn,jsone,jsonschema,libmozdata,matplotlib,microannotate,models,numpy,pandas,pkg_resources,pyemd,pytest,redis,requests,responses,rq,setuptools,shap,sklearn,tabulate,taskcluster,tqdm,xgboost,yaml,zstandard
known_third_party = apispec,apispec_webframeworks,dateutil,flask,flask_cors,hglib,imblearn,jsone,jsonschema,libmozdata,marshmallow,matplotlib,microannotate,models,numpy,pandas,pkg_resources,pyemd,pytest,redis,requests,responses,rq,setuptools,shap,sklearn,tabulate,taskcluster,tqdm,xgboost,yaml,zstandard

Просмотреть файл

@ -96,5 +96,6 @@ venv/
# Project-specific stuff
cache/
**/cache/
data/
http_service/

Просмотреть файл

@ -8,16 +8,42 @@ import logging
import os
import uuid
from flask import Flask, jsonify, request
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from apispec_webframeworks.flask import FlaskPlugin
from flask import Flask, jsonify, render_template, request
from flask_cors import cross_origin
from marshmallow import Schema, fields
from redis import Redis
from rq import Queue
from rq.exceptions import NoSuchJobError
from rq.job import Job
from .models import classify_bug
from bugbug import get_bugbug_version
from .models import MODELS_NAMES, classify_bug
API_TOKEN = "X-Api-Key"
API_DESCRIPTION = """
This is the documentation for the BubBug http service, the platform for Bugzilla Machine Learning projects.
# Introduction
This service can be used to classify a given bug using a pre-trained model.
You can classify a single bug or a batch of bugs.
The classification happens in the background so you need to call back the service for getting the results.
"""
spec = APISpec(
title="Bugbug",
version=get_bugbug_version(),
openapi_version="3.0.2",
info=dict(description=API_DESCRIPTION),
plugins=[FlaskPlugin(), MarshmallowPlugin()],
security=[{"api_key": []}],
)
application = Flask(__name__)
redis_url = os.environ.get("REDIS_URL", "redis://localhost/0")
redis_conn = Redis.from_url(redis_url)
@ -29,6 +55,37 @@ logging.basicConfig(level=logging.INFO)
LOGGER = logging.getLogger()
class BugPrediction(Schema):
prob = fields.List(fields.Float())
index = fields.Integer()
suggestion = fields.Str()
extra_data = fields.Dict()
class BugPredictionNotAvailableYet(Schema):
ready = fields.Boolean(enum=[False])
class ModelName(Schema):
model_name = fields.Str(enum=MODELS_NAMES, example="component")
class UnauthorizedError(Schema):
message = fields.Str(default="Error, missing X-API-KEY")
spec.components.schema(BugPrediction.__name__, schema=BugPrediction)
spec.components.schema(
BugPredictionNotAvailableYet.__name__, schema=BugPredictionNotAvailableYet
)
spec.components.schema(ModelName.__name__, schema=ModelName)
spec.components.schema(UnauthorizedError.__name__, schema=UnauthorizedError)
api_key_scheme = {"type": "apiKey", "in": "header", "name": "X-API-Key"}
spec.components.security_scheme("api_key", api_key_scheme)
def get_job_id():
return uuid.uuid4().hex
@ -85,15 +142,52 @@ def get_bug_classification(model_name, bug_id):
return None
@application.route("/<model_name>/predict/<bug_id>")
@application.route("/<model_name>/predict/<int:bug_id>")
@cross_origin()
def model_prediction(model_name, bug_id):
"""
---
get:
description: Classify a single bug using given model, answer either 200 if the bug is processed or 202 if the bug is being processed
summary: Classify a single bug
parameters:
- name: model_name
in: path
schema: ModelName
- name: bug_id
in: path
schema:
type: integer
example: 123456
responses:
200:
description: A single bug prediction
content:
application/json:
schema: BugPrediction
202:
description: A temporary answer for the bug being processed
content:
application/json:
schema:
type: object
properties:
ready:
type: boolean
enum: [False]
401:
description: API key is missing
content:
application/json:
schema: UnauthorizedError
"""
headers = request.headers
redis_conn.ping()
auth = headers.get(API_TOKEN)
if not auth:
return jsonify({"message": "Error, missing X-API-KEY"}), 401
return jsonify(UnauthorizedError().dump({}).data), 401
else:
LOGGER.info("Request with API TOKEN %r", auth)
@ -110,13 +204,125 @@ def model_prediction(model_name, bug_id):
@application.route("/<model_name>/predict/batch", methods=["POST"])
@cross_origin()
def batch_prediction(model_name):
"""
---
post:
description: >
Post a batch of bug ids to classify, answer either 200 if all bugs are
processed or 202 if at least one bug is not processed.
<br/><br/>
Starts by sending a batch of bugs ids like this:<br/>
```
{"bugs": [123, 456]}
```<br/><br>
You will likely get a 202 answer that indicates that no result is
available yet for any of the bug id you provided with the following
body:<br/>
```
{"bugs": {"123": {ready: False}, "456": {ready: False}}}
```<br/><br/>
Call back the same endpoint with the same bug ids a bit later, and you
will get the results.<br/><br/>
You might get the following output if some bugs are not available:
<br/>
```
{"bugs": {"123": {"available": False}}}
```<br/><br/>
And you will get the following output once the bugs are available:
<br/>
```
{"bugs": {"456": {"extra_data": {}, "index": 0, "prob": [0], "suggestion": ""}}}
```<br/><br/>
Please be aware that each bug could be in a different state, so the
following output, where a bug is returned and another one is still
being processed, is valid:
<br/>
```
{"bugs": {"123": {"available": False}, "456": {"extra_data": {}, "index": 0, "prob": [0], "suggestion": ""}}}
```
summary: Classify a batch of bugs
parameters:
- name: model_name
in: path
schema: ModelName
requestBody:
description: The list of bugs to classify
content:
application/json:
schema:
type: object
properties:
bugs:
type: array
items:
type: integer
examples:
cat:
summary: An example of payload
value:
bugs:
[123456, 789012]
responses:
200:
description: A list of results
content:
application/json:
schema:
type: object
additionalProperties: true
example:
bugs:
123456:
extra_data: {}
index: 0
prob: [0]
suggestion: string
789012:
extra_data: {}
index: 0
prob: [0]
suggestion: string
202:
description: A temporary answer for bugs being processed
content:
application/json:
schema:
type: object
items:
type: object
properties:
ready:
type: boolean
enum: [False]
example:
bugs:
123456:
extra_data: {}
index: 0
prob: [0]
suggestion: string
789012: {ready: False}
401:
description: API key is missing
content:
application/json:
schema: UnauthorizedError
"""
headers = request.headers
auth = headers.get(API_TOKEN)
if not auth:
return jsonify({"message": "Error, missing X-API-KEY"}), 401
return jsonify(UnauthorizedError().dump({}).data), 401
else:
LOGGER.info("Request with API TOKEN %r", auth)
@ -144,4 +350,21 @@ def batch_prediction(model_name):
# not like getting 1 million bug at a time
schedule_bug_classification(model_name, missing_bugs)
return jsonify(**data), status_code
return jsonify({"bugs": data}), status_code
@application.route("/swagger")
@cross_origin()
def swagger():
for name, rule in application.view_functions.items():
# Ignore static endpoint as it isn't documented with OpenAPI
if name == "static":
continue
spec.path(view=rule)
return jsonify(spec.to_dict())
@application.route("/doc")
def doc():
return render_template("doc.html")

Просмотреть файл

@ -1,4 +1,9 @@
apispec-webframeworks==0.4.0
apispec[yaml]==2.0.0
flask-apispec==0.8.0
flask-cors
Flask==1.0.3
gunicorn==19.9.0
rq==1.0
marshmallow==2.19.5
rq-dashboard==0.5.1
rq==1.0

Просмотреть файл

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<title>BugBug documentation</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url='{{ url_for("swagger") }}'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
</body>
</html>