JSON Schema v praxi

Historie je plná příběhů o tom, jak zaostalé kmeny dobudou vyspělou civilizaci, její výdobytky zavrhnou a v zemi následně zavládne několik staletí postupného znovuvynalézání. Totéž dnes sledujeme v přímém přenosu – JSON byl velkým protestem proti XML, jenže jak dospívá jeho vlastní ekosystém, přichází se na to, že některé staré nápady možná až tak špatné nebyly. Jedním z nich je koncept schématu – způsobu, jak zapsat, jakou strukturu a typy by měla určitá data mít. V dnešním článku se dovíte, jak můžete snadno JSON Schema využít ve své aplikaci a usnadnit si tak práci s validací dat.

Jak bylo naznačeno v úvodu článku, schéma popisuje strukturu a typy, jaké by měla data mít. XML i JSON jsou oba velice obecné formáty, takže je v nich možné zapsat téměř cokoliv. Chceme-li si data jen pro vlastní potřebu uložit a zase je načíst, schéma tolik neoceníme. Pokud bychom je ale rádi s někým sdíleli nebo je od někoho přijímali, bylo by už dobré druhé straně sdělit, jak mají vlastně vypadat – kde má být jaké políčko a jestli v něm má být číslo, nebo třeba seznam řetězců. To lze učinit buď psanou dokumentací, nebo strojovým popisem – schématem.

Výhodami strojového popisu je jednoznačnost a možnost vůči němu snadno a automaticky data validovat. Nejlepší pro protistranu samozřejmě je, když jí nabídneme obě varianty – jak popisnou dokumentaci, tak schéma.

Příklad ze života – užití schéma pro export z e-shopu

Představte si, že programujete e-shop a chcete v něm mít XML export pro Heureku. Najdete si dokumentaci, která vám dává představu o podobě dat, jaké máte posílat, a dáte se do programování. Jenže lidský popis není vše – někdy jsou v něm nejasné formulace a rozhodně se vůči němu dost špatně automaticky validuje. Často se tak stane, že máte ve svém systému chybu, ta vygeneruje špatné XML, na Heurece to udělá neplechu a vám pak utíkají milionové tržby, ani o tom nevíte. Kdyby Heureka publikovala přesné schéma pro svůj XML feed, mohl by si každý automaticky zkontrolovat, zda produkuje správně strukturovaná data – tedy zda dobře pochopil dokumentaci a neudělal žádnou chybu při implementaci. Stejně tak i Heureka by mohla podle téhož schéma snadno validovat příchozí XML a upozorňovat e-shopy na chyby. (Chybějící schéma se dnes dohání externími nástroji.)

JSON Schema

Ten samý problém lze nyní elegantně řešit i pro JSON díky JSON Schema. Pokud vás hned z hlavy nenapadají situace, kde se to může hodit, zkusím jich pár nabídnout:

  • Můžete schématem popsat své API, jako to udělalo Heroku. Budete systematicky validovat data, která do vašeho API přicházejí. Používáte Apiary? I tam je podpora pro JSON Schema!
  • Počkáte si, až bude v HTML možné posílat formuláře s enctype="application/json" a pak si je budete přes schéma validovat. K jedné definici pravidel dostanete zdarma dvě implementace – podle téhož schématu můžete validovat jak na serveru, tak i v JavaScriptu na klientovi.
  • Máte aplikaci s konfiguračním souborem v JSONu a chcete dát uživateli příjemnější chybovou hlášku, pokud se někde splete, než jen Config file invalid, see docs.

Nebudu vás v tomto článku zásobit příklady, jak má JSON Schema vypadat, ani opisovat pěknou příručku, která podrobně celou technologii vysvětluje. Chtěl bych vám prakticky ukázat, jak můžete jednoduše JSON Schema včlenit do své aplikace.

Knihovny pro validaci

Kontrolu podle schéma samozřejmě nebudeme dělat ručně – prvním krokem tedy bude nalezení vhodné knihovny pro náš jazyk. Moje příklady budou v Pythonu, takže se podívám na seznam software pro JSON Schema a vyberu si tu nejpoužívanější, jsonschema. Pojďme se v interaktivním Pythonu podívat, jak práce s knihovnou vypadá:

$ pip install jsonschema
$ python
>>> schema = {
...     'type' : 'object',
...     'properties' : {
...         'age' : {'type' : 'number'},
...         'name' : {'type' : 'string'},
...     },
... }

Takto jsme si připravili jednoduché schéma. Není to samozřejmě přímo JSON, ale jeho reprezentace v Pythonu pomocí datové struktury dict. Nyní zkusme oproti tomuto schéma validovat nějaká data:

>>> from jsonschema import validate
>>> validate({'name' : 'Honza', 'age' : 42}, schema)

Výstupem posledního řádku nebude nic. Což je dobře, protože funkce validate nic neprodukuje, pokud ji nakrmíme platným vstupem. Zkusme ji trošku pozlobit:

>>> validate({'name' : 'Honza', 'age' : 'Life, the Universe and Everything'}, schema)                                   
Traceback (most recent call last):
    ...
ValidationError: 'Life, the Universe and Everything' is not of type 'number'

Failed validating 'type' in schema['properties']['age']:
    {'type': 'number'}

On instance['age']:
    'Life, the Universe and Everything'

Vstup neprošel – přesně jak jsme očekávali. validate vyhodilo výjimku, z níž můžeme dokonce zjistit nejrůznější detaily o tom, kde a co přesně nesedí.

Takovéto jednoduché použití najdete samozřejmě i na první stránce v dokumentaci. My si dále na vlastním příkladě ukážeme, jak lze schémata načítat ze souboru, jak je vnořovat do sebe a proč je důležité, aby se naše knihovna nezastavila u první chyby.

Praktická ukázka – validace vstupních dat ve vašem API

Nejdříve si připravíme kousek našeho fiktivního API. K tomu budeme potřebovat webový framework – vezměme tedy něco hodně jednoduchého, řekněme Flask. Přibereme si k němu i nějaký testovací nástroj, ať můžeme rychle odzkoušet, zda nám vše funguje:

$ pip install flask pytest

V souboru web.py načrtneme jednoduché view, které bude odpovídat na POST požadavky a přijímat na cestě /users data k založení nového uživatele:

from uuid import uuid4 as uuid
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/users', methods=['POST'])
def users():
    data = request.get_json()
    data['id'] = uuid()  # predstirame ukladani do databaze
    return jsonify(data), 201

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0')

Následně si do souboru tests.py napíšeme test, který odesílá data na adresu /users a snaží se nového uživatele založit:

import json, pytest

@pytest.fixture
def test_client():
    from web import app
    app.config['TESTING'] = True
    return app.test_client()

def test_send_data(test_client):
    data_in = {'name': 'Honza', 'age': 42}
    response = test_client.post('/users', data=json.dumps(data_in),
                                content_type='application/json')
    assert response.status_code == 201

    data_out = json.loads(response.data)
    assert data_out['id']
    assert data_out['name'] == data_in['name']
    assert data_out['age'] == data_in['age']

Takovýto test projde – spustíme-li v konzoli py.test tests.py, bude na nás vše zeleně svítit. Pokud si aplikaci nastartujete přes python web.py, najdete ji v prohlížeči na http://localhost:5000/users, ale nic moc kloudného vám tam asi neodpoví, protože vůbec neobsluhujeme metodu GET. Lepší to bude třeba s curl:

$ curl -X POST -H 'Content-Type: application/json' -d '{"name":"Honza","age":42}' 'http://localhost:5000/users'
{
  "id": "79e015d7-a3f6-4619-867a-9c8fcf6f0d56",
  "age": 42, 
  "name": "Honza"
}

Kód k této fázi naleznete zde (odkaz přímo na commit).

Validujeme!

Kostru máme hotovou. Nyní zajistíme, aby nám do aplikace neproniklo nic, co neodpovídá naším požadavkům. Nejdříve si do souboru user.json sepíšeme jednoduché schéma:

{
    "$schema": "http://json-schema.org/schema#",
    "title": "User",
    "type": "object",
    "properties": {
        "age": {"type": "integer"},
        "name": {"type": "string"}
    },
    "required": ["name"],
    "additionalProperties": false
}

Jak vidíme, data reprezentující uživatele mají mít pole age pro věk, jejíž hodnotou bude číslo, a pole name pro jméno, jehož hodnotou má být řetězec. Jméno bude přitom povinné a nedovolujeme nastavit žádná další pole. Nyní začneme připravovat validaci pro naše view:

import json
from jsonschema import Draft4Validator as Validator

def validate(schema_filename, data):
    with open(schema_filename) as f:
        schema = json.load(f)  # cteme JSON Schema primo ze souboru
    Validator.check_schema(schema)  # zkontroluje schema nebo vyhodi vyjimku

    validator = Validator(schema)
    return validator.iter_errors(data)  # vraci chyby jednu po druhe

Nepoužili jsme funkci validate ze začátku článku, ale píšeme si vlastní, na míru. Dovolí nám to později lépe kontrolovat proces validace. Vybíráme si specifikaci JSON Schema, s níž chceme pracovat – v tomto případě Draft4. Nejdříve čteme schéma ze souboru, parsujeme jej a kontrolujeme, zda je ono samo vůbec v pořádku. Následně podle něj validujeme data a vracíme iterátor s jednotlivými chybami. Díky tomu, že se naše knihovna při použití iter_errors nezastaví na první chybě, budeme moci uživateli našeho API sdělit všechny nedostatky jím zaslaných dat najednou. Podívejme se, jak bude vypadat samotné view a testy:

import os

@app.route('/users', methods=['POST'])
def users():
    data = request.get_json()

    schema_filename = os.path.join(app.root_path, 'user.json')
    errors = [error.message for error in validate(schema_filename, data)]
    if errors:
        return jsonify(errors=errors), 400

    data['id'] = uuid()  # predstirame ukladani do databaze
    return jsonify(data), 201
def test_invalid_data(test_client):
    data_in = {'id': 2, 'name': 'Honza', 'age': 'Life, the Universe and Everything'}
    response = test_client.post('/users', data=json.dumps(data_in),
                                content_type='application/json')
    assert response.status_code == 400

    data_out = json.loads(response.data)
    assert len(data_out['errors']) == 2

Jedna poučka praví, že HTTP kódy 5xx indikují selhání na straně tvůrce serveru, zatímco 4xx mají hlásit problém na straně uživatele. Budeme se jí tedy držet a vracíme chybovou odpověď s HTTP kódem 400. Jestliže selže něco jiného, tak spoléháme na skutečnost, že Flask umí z nezachycených výjimek automaticky vytvořit „pětistovku“ (byť v tomto základním nastavení se bude jednat o HTML stránku a ne o JSON – úprava tohoto chování je ovšem nad rámec ukázky). Zkusíme-li teď poslat stejná data s curl, odpoví nám server takto:

$ curl -X POST -H "Content-Type: application/json" -d '{"id": 2,"name":"Honza","age":"Life, the Universe and Everything"}' 'http://localhost:5000/users'
{
  "errors": [
    "Additional properties are not allowed (u'id' was unexpected)", 
    "u'Life, the Universe and Everything' is not of type u'integer'"
  ]
}

Jak vidíme, „zadarmo“ jsme získali mocný validační mechanismus a k němu navíc docela ucházející výchozí chybové hlášky, z nichž si dokáže uživatel našeho API snadno a rychle domyslet, co dělá špatně.

Kdyby to viděl Horst Fuchs, přihodil by na stůl ještě skutečnost, že nyní máme validaci založenou na jednoduchém textovém souboru (vhodné např. k verzování), jehož obsah je v nezávislém, standardním formátu (řádka knihoven pro různé jazyky). Můžeme tak bez problémů tuto „specifikaci“ pro data přijímaná naším API sdílet – a když na to přijde, třeba rovnou na adrese /users/schema. Snadno lze na takové schéma potom odkázat z dokumentace, nebo přímo z odpovědí v API, např. přes hlavičky.

Kód k této fázi naleznete zde (odkaz přímo na commit).

Vnořená schémata

Představte si, že chceme k uživateli ukládat adresu. Mohli bychom ji samozřejmě snadno dopsat do našeho schéma v user.json jako vnořený objekt, ale brzy bychom narazili na to, že adresu ukládáme např. i u firem a musíme její definici udržovat na více místech zároveň. Zkusíme si tedy ukázat, jak bychom mohli ukládat adresu do zvláštního schéma a z popisu uživatele na ni jen odkázat. Nejdříve připravíme schémata:

{
    "$schema": "http://json-schema.org/schema#",
    "title": "User",
    "type": "object",
    "properties": {
        "age": {"type": "integer"},
        "name": {"type": "string"},
        "address": {"$ref": "address.json#"}
    },
    "required": ["name"],
    "additionalProperties": false
}

Jak je vidět, odkazujeme na soubor address.json. Pojďme jej vytvořit:

{
    "$schema": "http://json-schema.org/schema#",
    "title": "Address",
    "type": "object",
    "properties": {
        "street": {"type": "string"},
        "number": {"type": "integer"},
        "city": {"type": "string"},
        "country": {"type": "string"},
        "zip_code": {"type": "integer"}
    },
    "required": ["country", "city"],
    "additionalProperties": false
}

Aby knihovna jsonschema při validaci dokázala odkazovaný soubor najít, musíme rozšířit naši funkci validate a přidat do ní tzv. RefResolver, jenž nasměrujeme do správné složky:

from jsonschema import RefResolver

def validate(schema_filename, data):
    with open(schema_filename) as f:
        schema = json.load(f)  # cteme JSON Schema primo ze souboru
    Validator.check_schema(schema)  # zkontroluje schema nebo vyhodi vyjimku

    base_uri = 'file://' + os.path.dirname(schema_filename) + '/'
    resolver = RefResolver(base_uri, schema)
    validator = Validator(schema, resolver=resolver)
    return validator.iter_errors(data)  # vraci chyby jednu po druhe

A to je vše. Nyní nám zbývá už jen validaci adres odzkoušet v testech:

def test_send_address(test_client):
    data_in = {'name': 'Honza',
               'address': {'country': 'Czech Republic', 'city': 'Krno'}}
    response = test_client.post('/users', data=json.dumps(data_in),
                                content_type='application/json')
    assert response.status_code == 201

    data_out = json.loads(response.data)
    assert data_out['id']
    assert data_out['name'] == data_in['name']
    assert data_out['address'] == data_in['address']

def test_invalid_address(test_client):
    data_in = {'name': 'Honza',
               'address': {'city': 'Krno', 'number': '11', 'mayor': 'Rumun'}}
    response = test_client.post('/users', data=json.dumps(data_in),
                                content_type='application/json')
    assert response.status_code == 400

    data_out = json.loads(response.data)
    assert len(data_out['errors']) == 3

Hotovo. Finální kód naší malé aplikace je kompletně k dispozici v repozitáři na GitHubu. Celá validace se pak na základě dalších vylepšení dá slušně automatizovat – do de facto deklarativní roviny ji přesunulo rozšíření Flask-JsonSchema:

@app.route('/users', methods=['POST'])
@jsonschema.validate('user', 'create')
def users():
   ...

Závěrem

Cílem článku nebylo vytvořit dokonalou a neprůstřelnou aplikaci nebo hlásat jediný správný způsob, jak něco dělat. Také Python byl vybrán spíše pro názornost – stejný příklad byste mohli sestrojit v Javě nebo JavaScriptu.

Mým záměrem bylo představit schéma jako obecnou technologii, která je u nás bohužel podužívaná a přitom by mohla vyřešit mnoho zbytečných problémů na obou stranách „API barikády“. Chtěl jsem prakticky ukázat, že JSON Schema není žádnou pochybnou, nostalgickou iniciativou XML nadšenců, které někdo donutil přejít na JSON. A v neposlední řadě jsem si přál vyvolat ve vás zájem, jenž by způsobil, že si otevřete návod k JSON Schema a podíváte se, co všechno dokáže validovat a jaké má pokročilé funkce, nebo že se zamyslíte nad tím, jaké možnosti se vám užitím schématu otevírají. Snad se mi to povedlo.

Honza je programátor. Od roku 2011 buduje českou komunitu kolem jazyka Python. V současnosti pomáhá hlavně s propagací aktivit, jako jsou PyLadies, Pyvo, nebo PyCon CZ. Přes den jej najdete v Apiary, kde se stará o Dredd, framework na testování API. Občas taky radí lidem jak mají API dělat a přednáší o tom na konferencích.

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Komentáře: 5

Přehled komentářů

odhlasit Když validovat, tak pořádně
Lukáš Vlček Číslo řádku s validační chybou?
Honza Javorek Re: Číslo řádku s validační chybou?
Lukáš Vlček Číslo řádku s validační chybou? (cont...)
Honza Javorek Re: Číslo řádku s validační chybou? (cont...)
Zdroj: https://www.zdrojak.cz/?p=12037