├── .coveragerc ├── .github └── workflows │ ├── black.yml │ ├── build.yml │ ├── codeql-analysis.yml │ ├── mypy.yml │ └── ossar-analysis.yml ├── .gitignore ├── CHANGES ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── example ├── example.py └── requirements.txt ├── marshmallow_jsonschema ├── __init__.py ├── base.py ├── exceptions.py ├── extensions │ ├── __init__.py │ └── react_jsonschema_form.py └── validation.py ├── pyproject.toml ├── requirements-test.txt ├── requirements-tox.txt ├── requirements.txt ├── setup.py ├── tests ├── __init__.py ├── test_additional_properties.py ├── test_dump.py ├── test_imports.py ├── test_react_extension.py └── test_validation.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | tests/* 5 | */tests/* 6 | 7 | [report] 8 | skip_covered = True 9 | fail_under=90 10 | exclude_lines = 11 | pragma: no cover 12 | def __repr__ 13 | raise NotImplementedError 14 | 15 | [html] 16 | directory = .reports/coverage 17 | -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: psf/black@stable 11 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: ["push", "pull_request"] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | # supported python versions can be found here 11 | # https://github.com/actions/python-versions/releases 12 | # 13 | # Please bump to the latest unreleased candidate 14 | # when you come across this and have a moment to spare! 15 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 16 | steps: 17 | - uses: actions/checkout@master 18 | - name: set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -U tox tox-gh-actions 26 | pip install -r requirements-tox.txt -r requirements-test.txt 27 | - name: Run tox 28 | run: | 29 | tox -e py 30 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '40 17 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/mypy.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | paths: 6 | - '*.py' 7 | 8 | jobs: 9 | mypy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Setup Python 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: 3.11.2 16 | architecture: x64 17 | - name: Checkout 18 | uses: actions/checkout@v1 19 | - name: Install mypy 20 | run: pip install mypy 21 | - name: Run mypy 22 | uses: sasanquaneuf/mypy-github-action@releases/v1 23 | with: 24 | checkName: 'mypy' # NOTE: this needs to be the same as the job name 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/ossar-analysis.yml: -------------------------------------------------------------------------------- 1 | # This workflow integrates a collection of open source static analysis tools 2 | # with GitHub code scanning. For documentation, or to provide feedback, visit 3 | # https://github.com/github/ossar-action 4 | name: OSSAR 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | # The branches below must be a subset of the branches above 11 | branches: [ master ] 12 | schedule: 13 | - cron: '29 11 * * 2' 14 | 15 | jobs: 16 | OSSAR-Scan: 17 | # OSSAR runs on windows-latest. 18 | # ubuntu-latest and macos-latest support coming soon 19 | runs-on: windows-latest 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v2 24 | 25 | # Ensure a compatible version of dotnet is installed. 26 | # The [Microsoft Security Code Analysis CLI](https://aka.ms/mscadocs) is built with dotnet v3.1.201. 27 | # A version greater than or equal to v3.1.201 of dotnet must be installed on the agent in order to run this action. 28 | # GitHub hosted runners already have a compatible version of dotnet installed and this step may be skipped. 29 | # For self-hosted runners, ensure dotnet version 3.1.201 or later is installed by including this action: 30 | # - name: Install .NET 31 | # uses: actions/setup-dotnet@v1 32 | # with: 33 | # dotnet-version: '3.1.x' 34 | 35 | # Run open source static analysis tools 36 | - name: Run OSSAR 37 | uses: github/ossar-action@v1 38 | id: ossar 39 | 40 | # Upload results to the Security tab 41 | - name: Upload OSSAR results 42 | uses: github/codeql-action/upload-sarif@v1 43 | with: 44 | sarif_file: ${{ steps.ossar.outputs.sarifFile }} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | 0.13.0 (2021-10-21) 2 | - Fixes to field default #151 3 | - The default value for nested fields wasn't serialized. 4 | - The default value for other fields may be a callable and in that case it shouldn't be emitted into the schema. 5 | - set int to "integer" instead of "number" #152 6 | - Added fields.IPInterface marshmallow field type to python types mapping #155 7 | - 0.13.x is planned to be the last major release to officially support python 3.6 8 | which is EOL in December 2021 9 | - minimum supported version of marshmallow is currently 3.11.0 (technically this was 10 | true as of 0.12.0 because of the use of fields.IPInterface 11 | 0.12.0 (2021-05-22) 12 | - Add support for validate.Equal #135 13 | - Added fields.IP marshmallow field type to python types mapping #137 14 | - Use data_key when available for property names #139 15 | - UUID field inherits from the String field #144 16 | - fix: Change readonly to readOnly #147 17 | 0.11.1 (2021-01-28) 18 | - adding typing support and mypy to the build 19 | 0.11.0 (2021-01-26) 20 | - drop support for python 2 & 3.5, as well as marshmallow 2. 21 | Python >= 3.6 and marshmallow >= 3 are now required! 22 | Python 3.5 should still work - no breaking changes yet, 23 | it just isn't a part of the build anymore. #116 24 | - add optional support for marshmallow_enum and marshmallow_union. 25 | - Include type of Dict values #127 26 | Add support for specifying the type of Dict values. 27 | Prior to this change any information about the values in a 28 | dict - particularly nested schemas - was lost. 29 | - fix ReactJsonSchemaFormJSONSchema for marshmallow>=3.10.0 30 | - Change Makefile to build and upload wheel #131 31 | - move from travisci to github actions #132 32 | 0.10.0 (2020-03-03) 33 | - added ReactJsonSchemaFormJSONSchema extension 34 | - Add support for allow_none (#106 thanks @avilaton!) 35 | 0.9.0 (2020-01-18) 36 | 37 | 0.8.0 (2019-10-08) 38 | 39 | 0.7.0 (2019-08-11) 40 | 41 | 0.6.0 (2019-06-16) 42 | - lots of various fixes 43 | - fix compatibility with brutusin/json-form 44 | - drop support for python 3.3 45 | - fix BC breaks in marshmallow 3 (someday it will be released!!) 46 | - probably (hopefully?) the last major version to support py2! 47 | 48 | 0.5.0 (2018-07-17) 49 | - support for marshmallow 3 50 | 51 | 0.4.0 (2017-07-13) 52 | - add support for fields.List (thanks @Bartvds and @sdayu 53 | for tests & implementation) 54 | 55 | 0.3.0 (2016-06-12) 56 | - add support for marshmallow validators (see #14) 57 | 58 | 0.2.0 (2016-05-25) 59 | - add support for titles & descriptions in metadata 60 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Setting Up for Local Development 2 | ******************************** 3 | 4 | 1. Fork marshmallow_jsonschema on Github. 5 | 6 | :: 7 | 8 | $ git clone https://github.com/fuhrysteve/marshmallow-jsonschema.git 9 | $ cd marshmallow_jsonschema 10 | 11 | 2. Create a virtual environment and install all dependencies 12 | 13 | :: 14 | 15 | $ make venv 16 | 17 | 3. Install the pre-commit hooks, which will format and lint your git staged files. 18 | 19 | :: 20 | 21 | # The pre-commit CLI was installed above 22 | $ pre-commit install --allow-missing-config 23 | 24 | 25 | Running tests 26 | ************* 27 | 28 | To run all tests: :: 29 | 30 | $ pytest 31 | 32 | To run syntax checks: :: 33 | 34 | $ tox -e lint 35 | 36 | (Optional) To run tests in all supported Python versions in their own virtual environments (must have each interpreter installed): :: 37 | 38 | $ tox 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Stephen J. Fuhry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md requirements.txt requirements-test.txt requirements-tox.txt 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT = marshmallow_jsonschema 2 | 3 | PYTHON_VERSION ?= 3.8 4 | VIRTUAL_ENV ?= .venv 5 | PYTHON ?= $(VIRTUAL_ENV)/bin/python 6 | 7 | 8 | REQUIREMENTS = requirements.txt 9 | REQUIREMENTS_TEST = requirements-test.txt 10 | REQUIREMENTS_TOX = requirements-tox.txt 11 | 12 | SHELL := /bin/bash -euo pipefail 13 | 14 | venv_init: 15 | pip install virtualenv 16 | if [ ! -d $(VIRTUAL_ENV) ]; then \ 17 | virtualenv -p python$(PYTHON_VERSION) --prompt="($(PROJECT))" $(VIRTUAL_ENV); \ 18 | fi 19 | 20 | venv: venv_init 21 | $(VIRTUAL_ENV)/bin/pip install -r $(REQUIREMENTS) 22 | $(VIRTUAL_ENV)/bin/pip install -r $(REQUIREMENTS_TEST) 23 | $(VIRTUAL_ENV)/bin/pip install -r $(REQUIREMENTS_TOX) 24 | 25 | tox: 26 | tox 27 | 28 | test: 29 | pytest 30 | 31 | test_coverage: 32 | pytest --cov-report html --cov-config .coveragerc --cov $(PROJECT) 33 | 34 | clean_build_and_dist: 35 | if [ -d build/ ]; then \ 36 | rm -rf build/ dist/ ; \ 37 | fi 38 | 39 | sdist: clean_build_and_dist 40 | python setup.py sdist 41 | 42 | bdist_wheel: 43 | pip install -U wheel 44 | python setup.py bdist_wheel 45 | 46 | twine: 47 | pip install -U twine 48 | 49 | pypitest: sdist bdist_wheel twine 50 | twine upload -r pypitest dist/* 51 | 52 | pypi: sdist bdist_wheel twine 53 | twine upload -r pypi dist/* 54 | 55 | 56 | clean_venv: 57 | rm -rf $(VIRTUAL_ENV) 58 | 59 | clean_pyc: 60 | find . -name \*.pyc -delete 61 | 62 | clean: clean_venv clean_pyc 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## marshmallow-jsonschema: JSON Schema formatting with marshmallow 2 | 3 | ![Build Status](https://github.com/fuhrysteve/marshmallow-jsonschema/workflows/build/badge.svg) 4 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) 5 | 6 | marshmallow-jsonschema translates marshmallow schemas into 7 | JSON Schema Draft v7 compliant jsonschema. See http://json-schema.org/ 8 | 9 | #### Why would I want my schema translated to JSON? 10 | 11 | What are the use cases for this? Let's say you have a 12 | marshmallow schema in python, but you want to render your 13 | schema as a form in another system (for example: a web browser 14 | or mobile device). 15 | 16 | #### Installation 17 | 18 | Requires python>=3.6 and marshmallow>=3.11. (For python 2 & marshmallow 2 support, please use marshmallow-jsonschema<0.11) 19 | 20 | ``` 21 | pip install marshmallow-jsonschema 22 | ``` 23 | 24 | #### Some Client tools can render forms using JSON Schema 25 | 26 | * [react-jsonschema-form](https://github.com/mozilla-services/react-jsonschema-form) (recommended) 27 | * See below extension for this excellent library! 28 | * https://github.com/brutusin/json-forms 29 | * https://github.com/jdorn/json-editor 30 | * https://github.com/ulion/jsonform 31 | 32 | ### Examples 33 | 34 | #### Simple Example 35 | 36 | ```python 37 | from marshmallow import Schema, fields 38 | from marshmallow_jsonschema import JSONSchema 39 | 40 | class UserSchema(Schema): 41 | username = fields.String() 42 | age = fields.Integer() 43 | birthday = fields.Date() 44 | 45 | user_schema = UserSchema() 46 | 47 | json_schema = JSONSchema() 48 | json_schema.dump(user_schema) 49 | ``` 50 | 51 | Yields: 52 | 53 | ```python 54 | {'properties': {'age': {'format': 'integer', 55 | 'title': 'age', 56 | 'type': 'number'}, 57 | 'birthday': {'format': 'date', 58 | 'title': 'birthday', 59 | 'type': 'string'}, 60 | 'username': {'title': 'username', 'type': 'string'}}, 61 | 'required': [], 62 | 'type': 'object'} 63 | ``` 64 | 65 | #### Nested Example 66 | 67 | ```python 68 | from marshmallow import Schema, fields 69 | from marshmallow_jsonschema import JSONSchema 70 | from tests import UserSchema 71 | 72 | 73 | class Athlete(object): 74 | user_schema = UserSchema() 75 | 76 | def __init__(self): 77 | self.name = 'sam' 78 | 79 | 80 | class AthleteSchema(Schema): 81 | user_schema = fields.Nested(JSONSchema) 82 | name = fields.String() 83 | 84 | 85 | athlete = Athlete() 86 | athlete_schema = AthleteSchema() 87 | 88 | athlete_schema.dump(athlete) 89 | ``` 90 | 91 | #### Complete example Flask application using brutisin/json-forms 92 | 93 | ![Screenshot](http://i.imgur.com/jJv1wFk.png) 94 | 95 | This example renders a form not dissimilar to how [wtforms](https://github.com/wtforms/wtforms) might render a form. 96 | 97 | However rather than rendering the form in python, the JSON Schema is rendered using the 98 | javascript library [brutusin/json-forms](https://github.com/brutusin/json-forms). 99 | 100 | 101 | ```python 102 | from flask import Flask, jsonify 103 | from marshmallow import Schema, fields 104 | from marshmallow_jsonschema import JSONSchema 105 | 106 | app = Flask(__name__) 107 | 108 | 109 | class UserSchema(Schema): 110 | name = fields.String() 111 | address = fields.String() 112 | 113 | 114 | @app.route('/schema') 115 | def schema(): 116 | schema = UserSchema() 117 | return jsonify(JSONSchema().dump(schema)) 118 | 119 | 120 | @app.route('/') 121 | def home(): 122 | return ''' 123 | 124 | 125 | 126 | 127 | 128 | 141 | 142 | 143 |
144 | 145 | 146 | ''' 147 | 148 | 149 | if __name__ == '__main__': 150 | app.run(host='0.0.0.0', debug=True) 151 | 152 | ``` 153 | 154 | 155 | ### Advanced usage 156 | #### Custom Type support 157 | 158 | Simply add a `_jsonschema_type_mapping` method to your field 159 | so we know how it ought to get serialized to JSON Schema. 160 | 161 | A common use case for this is creating a dropdown menu using 162 | enum (see Gender below). 163 | 164 | 165 | ```python 166 | class Colour(fields.Field): 167 | 168 | def _jsonschema_type_mapping(self): 169 | return { 170 | 'type': 'string', 171 | } 172 | 173 | def _serialize(self, value, attr, obj): 174 | r, g, b = value 175 | r = "%02X" % (r,) 176 | g = "%02X" % (g,) 177 | b = "%02X" % (b,) 178 | return '#' + r + g + b 179 | 180 | class Gender(fields.String): 181 | def _jsonschema_type_mapping(self): 182 | return { 183 | 'type': 'string', 184 | 'enum': ['Male', 'Female'] 185 | } 186 | 187 | 188 | class UserSchema(Schema): 189 | name = fields.String(required=True) 190 | favourite_colour = Colour() 191 | gender = Gender() 192 | 193 | schema = UserSchema() 194 | json_schema = JSONSchema() 195 | json_schema.dump(schema) 196 | ``` 197 | 198 | 199 | ### React-JSONSchema-Form Extension 200 | 201 | [react-jsonschema-form](https://react-jsonschema-form.readthedocs.io/en/latest/) 202 | is a library for rendering jsonschemas as a form using React. It is very powerful 203 | and full featured.. the catch is that it requires a proprietary 204 | [`uiSchema`](https://react-jsonschema-form.readthedocs.io/en/latest/form-customization/#the-uischema-object) 205 | to provide advanced control how the form is rendered. 206 | [Here's a live playground](https://rjsf-team.github.io/react-jsonschema-form/) 207 | 208 | *(new in version 0.10.0)* 209 | 210 | ```python 211 | from marshmallow_jsonschema.extensions import ReactJsonSchemaFormJSONSchema 212 | 213 | class MySchema(Schema): 214 | first_name = fields.String( 215 | metadata={ 216 | 'ui:autofocus': True, 217 | } 218 | ) 219 | last_name = fields.String() 220 | 221 | class Meta: 222 | react_uischema_extra = { 223 | 'ui:order': [ 224 | 'first_name', 225 | 'last_name', 226 | ] 227 | } 228 | 229 | 230 | json_schema_obj = ReactJsonSchemaFormJSONSchema() 231 | schema = MySchema() 232 | 233 | # here's your jsonschema 234 | data = json_schema_obj.dump(schema) 235 | 236 | # ..and here's your uiSchema! 237 | ui_schema_json = json_schema_obj.dump_uischema(schema) 238 | -------------------------------------------------------------------------------- /example/example.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, jsonify 2 | from marshmallow import Schema, fields 3 | from marshmallow_jsonschema import JSONSchema 4 | 5 | app = Flask(__name__) 6 | 7 | 8 | class UserSchema(Schema): 9 | name = fields.String() 10 | address = fields.String() 11 | 12 | 13 | @app.route("/schema") 14 | def schema(): 15 | schema = UserSchema() 16 | return jsonify(JSONSchema().dump(schema)) 17 | 18 | 19 | @app.route("/") 20 | def home(): 21 | return """ 22 | 23 | 24 | 25 | 26 | 27 | 40 | 41 | 42 |
43 | 44 | 45 | """ 46 | 47 | 48 | if __name__ == "__main__": 49 | app.run(host="0.0.0.0", debug=True) 50 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask>=1.1.1 2 | marshmallow>=3 3 | marshmallow-jsonschema>=0.9.0 4 | -------------------------------------------------------------------------------- /marshmallow_jsonschema/__init__.py: -------------------------------------------------------------------------------- 1 | from pkg_resources import get_distribution 2 | 3 | __version__ = get_distribution("marshmallow-jsonschema").version 4 | __license__ = "MIT" 5 | 6 | from .base import JSONSchema 7 | from .exceptions import UnsupportedValueError 8 | 9 | __all__ = ("JSONSchema", "UnsupportedValueError") 10 | -------------------------------------------------------------------------------- /marshmallow_jsonschema/base.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import decimal 3 | import uuid 4 | from enum import Enum 5 | from inspect import isclass 6 | import typing 7 | 8 | from marshmallow import fields, missing, Schema, validate 9 | from marshmallow.class_registry import get_class 10 | from marshmallow.decorators import post_dump 11 | from marshmallow.utils import _Missing 12 | 13 | from marshmallow import INCLUDE, EXCLUDE, RAISE 14 | 15 | try: 16 | from marshmallow_union import Union 17 | 18 | ALLOW_UNIONS = True 19 | except ImportError: 20 | ALLOW_UNIONS = False 21 | 22 | try: 23 | from marshmallow_enum import EnumField, LoadDumpOptions 24 | 25 | ALLOW_ENUMS = True 26 | except ImportError: 27 | ALLOW_ENUMS = False 28 | 29 | from .exceptions import UnsupportedValueError 30 | from .validation import ( 31 | handle_equal, 32 | handle_length, 33 | handle_one_of, 34 | handle_range, 35 | handle_regexp, 36 | ) 37 | 38 | __all__ = ("JSONSchema",) 39 | 40 | PY_TO_JSON_TYPES_MAP = { 41 | dict: {"type": "object"}, 42 | list: {"type": "array"}, 43 | datetime.time: {"type": "string", "format": "time"}, 44 | datetime.timedelta: { 45 | # TODO explore using 'range'? 46 | "type": "string" 47 | }, 48 | datetime.datetime: {"type": "string", "format": "date-time"}, 49 | datetime.date: {"type": "string", "format": "date"}, 50 | uuid.UUID: {"type": "string", "format": "uuid"}, 51 | str: {"type": "string"}, 52 | bytes: {"type": "string"}, 53 | decimal.Decimal: {"type": "number", "format": "decimal"}, 54 | set: {"type": "array"}, 55 | tuple: {"type": "array"}, 56 | float: {"type": "number", "format": "float"}, 57 | int: {"type": "integer"}, 58 | bool: {"type": "boolean"}, 59 | Enum: {"type": "string"}, 60 | } 61 | 62 | # We use these pairs to get proper python type from marshmallow type. 63 | # We can't use mapping as earlier Python versions might shuffle dict contents 64 | # and then `fields.Number` might end up before `fields.Integer`. 65 | # As we perform sequential subclass check to determine proper Python type, 66 | # we can't let that happen. 67 | MARSHMALLOW_TO_PY_TYPES_PAIRS = [ 68 | # This part of a mapping is carefully selected from marshmallow source code, 69 | # see marshmallow.BaseSchema.TYPE_MAPPING. 70 | (fields.UUID, uuid.UUID), 71 | (fields.String, str), 72 | (fields.Float, float), 73 | (fields.Raw, str), 74 | (fields.Boolean, bool), 75 | (fields.Integer, int), 76 | (fields.Time, datetime.time), 77 | (fields.Date, datetime.date), 78 | (fields.TimeDelta, datetime.timedelta), 79 | (fields.DateTime, datetime.datetime), 80 | (fields.Decimal, decimal.Decimal), 81 | # These are some mappings that generally make sense for the rest 82 | # of marshmallow fields. 83 | (fields.Email, str), 84 | (fields.Dict, dict), 85 | (fields.Url, str), 86 | (fields.List, list), 87 | (fields.Number, decimal.Decimal), 88 | (fields.IP, str), 89 | (fields.IPInterface, str), 90 | # This one is here just for completeness sake and to check for 91 | # unknown marshmallow fields more cleanly. 92 | (fields.Nested, dict), 93 | ] 94 | 95 | if ALLOW_ENUMS: 96 | # We currently only support loading enum's from their names. So the possible 97 | # values will always map to string in the JSONSchema 98 | MARSHMALLOW_TO_PY_TYPES_PAIRS.append((EnumField, Enum)) 99 | 100 | 101 | FIELD_VALIDATORS = { 102 | validate.Equal: handle_equal, 103 | validate.Length: handle_length, 104 | validate.OneOf: handle_one_of, 105 | validate.Range: handle_range, 106 | validate.Regexp: handle_regexp, 107 | } 108 | 109 | 110 | def _resolve_additional_properties(cls) -> bool: 111 | meta = cls.Meta 112 | 113 | additional_properties = getattr(meta, "additional_properties", None) 114 | if additional_properties is not None: 115 | if additional_properties in (True, False): 116 | return additional_properties 117 | else: 118 | raise UnsupportedValueError( 119 | "`additional_properties` must be either True or False" 120 | ) 121 | 122 | unknown = getattr(meta, "unknown", None) 123 | if unknown is None: 124 | return False 125 | elif unknown in (RAISE, EXCLUDE): 126 | return False 127 | elif unknown == INCLUDE: 128 | return True 129 | else: 130 | # This is probably unreachable as of marshmallow 3.16.0 131 | raise UnsupportedValueError("Unknown value %s for `unknown`" % unknown) 132 | 133 | 134 | class JSONSchema(Schema): 135 | """Converts to JSONSchema as defined by http://json-schema.org/.""" 136 | 137 | properties = fields.Method("get_properties") 138 | type = fields.Constant("object") 139 | required = fields.Method("get_required") 140 | 141 | def __init__(self, *args, **kwargs) -> None: 142 | """Setup internal cache of nested fields, to prevent recursion. 143 | 144 | :param bool props_ordered: if `True` order of properties will be save as declare in class, 145 | else will using sorting, default is `False`. 146 | Note: For the marshmallow scheme, also need to enable 147 | ordering of fields too (via `class Meta`, attribute `ordered`). 148 | """ 149 | self._nested_schema_classes: typing.Dict[str, typing.Dict[str, typing.Any]] = {} 150 | self.nested = kwargs.pop("nested", False) 151 | self.props_ordered = kwargs.pop("props_ordered", False) 152 | setattr(self.opts, "ordered", self.props_ordered) 153 | super().__init__(*args, **kwargs) 154 | 155 | def get_properties(self, obj) -> typing.Dict[str, typing.Dict[str, typing.Any]]: 156 | """Fill out properties field.""" 157 | properties = self.dict_class() 158 | 159 | if self.props_ordered: 160 | fields_items_sequence = obj.fields.items() 161 | else: 162 | if callable(obj): 163 | fields_items_sequence = sorted(obj().fields.items()) 164 | else: 165 | fields_items_sequence = sorted(obj.fields.items()) 166 | 167 | for field_name, field in fields_items_sequence: 168 | schema = self._get_schema_for_field(obj, field) 169 | properties[ 170 | field.metadata.get("name") or field.data_key or field.name 171 | ] = schema 172 | 173 | return properties 174 | 175 | def get_required(self, obj) -> typing.Union[typing.List[str], _Missing]: 176 | """Fill out required field.""" 177 | required = [] 178 | if callable(obj): 179 | field_items_iterable = sorted(obj().fields.items()) 180 | else: 181 | field_items_iterable = sorted(obj.fields.items()) 182 | for field_name, field in field_items_iterable: 183 | if field.required: 184 | required.append(field.data_key or field.name) 185 | 186 | return required or missing 187 | 188 | def _from_python_type(self, obj, field, pytype) -> typing.Dict[str, typing.Any]: 189 | """Get schema definition from python type.""" 190 | json_schema = {"title": field.attribute or field.name or ""} 191 | 192 | for key, val in PY_TO_JSON_TYPES_MAP[pytype].items(): 193 | json_schema[key] = val 194 | 195 | if field.dump_only: 196 | json_schema["readOnly"] = True 197 | 198 | if field.default is not missing and not callable(field.default): 199 | json_schema["default"] = field.default 200 | 201 | if ALLOW_ENUMS and isinstance(field, EnumField): 202 | json_schema["enum"] = self._get_enum_values(field) 203 | 204 | if field.allow_none: 205 | previous_type = json_schema["type"] 206 | json_schema["type"] = [previous_type, "null"] 207 | 208 | # NOTE: doubled up to maintain backwards compatibility 209 | metadata = field.metadata.get("metadata", {}) 210 | metadata.update(field.metadata) 211 | 212 | for md_key, md_val in metadata.items(): 213 | if md_key in ("metadata", "name"): 214 | continue 215 | json_schema[md_key] = md_val 216 | 217 | if isinstance(field, fields.List): 218 | json_schema["items"] = self._get_schema_for_field(obj, field.inner) 219 | 220 | if isinstance(field, fields.Dict): 221 | json_schema["additionalProperties"] = ( 222 | self._get_schema_for_field(obj, field.value_field) 223 | if field.value_field 224 | else {} 225 | ) 226 | return json_schema 227 | 228 | def _get_enum_values(self, field) -> typing.List[str]: 229 | assert ALLOW_ENUMS and isinstance(field, EnumField) 230 | 231 | if field.load_by == LoadDumpOptions.value: 232 | # Python allows enum values to be almost anything, so it's easier to just load from the 233 | # names of the enum's which will have to be strings. 234 | raise NotImplementedError( 235 | "Currently do not support JSON schema for enums loaded by value" 236 | ) 237 | 238 | return [value.name for value in field.enum] 239 | 240 | def _from_union_schema( 241 | self, obj, field 242 | ) -> typing.Dict[str, typing.List[typing.Any]]: 243 | """Get a union type schema. Uses anyOf to allow the value to be any of the provided sub fields""" 244 | assert ALLOW_UNIONS and isinstance(field, Union) 245 | 246 | return { 247 | "anyOf": [ 248 | self._get_schema_for_field(obj, sub_field) 249 | for sub_field in field._candidate_fields 250 | ] 251 | } 252 | 253 | def _get_python_type(self, field): 254 | """Get python type based on field subclass""" 255 | for map_class, pytype in MARSHMALLOW_TO_PY_TYPES_PAIRS: 256 | if issubclass(field.__class__, map_class): 257 | return pytype 258 | 259 | raise UnsupportedValueError("unsupported field type %s" % field) 260 | 261 | def _get_schema_for_field(self, obj, field): 262 | """Get schema and validators for field.""" 263 | if hasattr(field, "_jsonschema_type_mapping"): 264 | schema = field._jsonschema_type_mapping() 265 | elif "_jsonschema_type_mapping" in field.metadata: 266 | schema = field.metadata["_jsonschema_type_mapping"] 267 | else: 268 | if isinstance(field, fields.Nested): 269 | # Special treatment for nested fields. 270 | schema = self._from_nested_schema(obj, field) 271 | elif ALLOW_UNIONS and isinstance(field, Union): 272 | schema = self._from_union_schema(obj, field) 273 | else: 274 | pytype = self._get_python_type(field) 275 | schema = self._from_python_type(obj, field, pytype) 276 | # Apply any and all validators that field may have 277 | for validator in field.validators: 278 | if validator.__class__ in FIELD_VALIDATORS: 279 | schema = FIELD_VALIDATORS[validator.__class__]( 280 | schema, field, validator, obj 281 | ) 282 | else: 283 | base_class = getattr( 284 | validator, "_jsonschema_base_validator_class", None 285 | ) 286 | if base_class is not None and base_class in FIELD_VALIDATORS: 287 | schema = FIELD_VALIDATORS[base_class](schema, field, validator, obj) 288 | return schema 289 | 290 | def _from_nested_schema(self, obj, field): 291 | """Support nested field.""" 292 | if isinstance(field.nested, (str, bytes)): 293 | nested = get_class(field.nested) 294 | else: 295 | nested = field.nested 296 | 297 | if isclass(nested) and issubclass(nested, Schema): 298 | name = nested.__name__ 299 | only = field.only 300 | exclude = field.exclude 301 | nested_cls = nested 302 | nested_instance = nested(only=only, exclude=exclude, context=obj.context) 303 | elif callable(nested): 304 | nested_instance = nested() 305 | nested_type = type(nested_instance) 306 | name = nested_type.__name__ 307 | nested_cls = nested_type.__class__ 308 | else: 309 | nested_cls = nested.__class__ 310 | name = nested_cls.__name__ 311 | nested_instance = nested 312 | 313 | outer_name = obj.__class__.__name__ 314 | # If this is not a schema we've seen, and it's not this schema (checking this for recursive schemas), 315 | # put it in our list of schema defs 316 | if name not in self._nested_schema_classes and name != outer_name: 317 | wrapped_nested = self.__class__(nested=True) 318 | wrapped_dumped = wrapped_nested.dump(nested_instance) 319 | 320 | wrapped_dumped["additionalProperties"] = _resolve_additional_properties( 321 | nested_cls 322 | ) 323 | 324 | self._nested_schema_classes[name] = wrapped_dumped 325 | 326 | self._nested_schema_classes.update(wrapped_nested._nested_schema_classes) 327 | 328 | # and the schema is just a reference to the def 329 | schema = self._schema_base(name) 330 | 331 | # NOTE: doubled up to maintain backwards compatibility 332 | metadata = field.metadata.get("metadata", {}) 333 | metadata.update(field.metadata) 334 | 335 | for md_key, md_val in metadata.items(): 336 | if md_key in ("metadata", "name"): 337 | continue 338 | schema[md_key] = md_val 339 | 340 | if field.default is not missing and not callable(field.default): 341 | schema["default"] = nested_instance.dump(field.default) 342 | 343 | if field.many: 344 | schema = { 345 | "type": "array" if field.required else ["array", "null"], 346 | "items": schema, 347 | } 348 | 349 | return schema 350 | 351 | def _schema_base(self, name): 352 | return {"type": "object", "$ref": "#/definitions/{}".format(name)} 353 | 354 | def dump(self, obj, **kwargs): 355 | """Take obj for later use: using class name to namespace definition.""" 356 | self.obj = obj 357 | return super().dump(obj, **kwargs) 358 | 359 | @post_dump 360 | def wrap(self, data, **_) -> typing.Dict[str, typing.Any]: 361 | """Wrap this with the root schema definitions.""" 362 | if self.nested: # no need to wrap, will be in outer defs 363 | return data 364 | 365 | cls = self.obj.__class__ 366 | name = cls.__name__ 367 | 368 | data["additionalProperties"] = _resolve_additional_properties(cls) 369 | 370 | self._nested_schema_classes[name] = data 371 | root = { 372 | "$schema": "http://json-schema.org/draft-07/schema#", 373 | "definitions": self._nested_schema_classes, 374 | "$ref": "#/definitions/{name}".format(name=name), 375 | } 376 | return root 377 | -------------------------------------------------------------------------------- /marshmallow_jsonschema/exceptions.py: -------------------------------------------------------------------------------- 1 | class UnsupportedValueError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /marshmallow_jsonschema/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from .react_jsonschema_form import ReactJsonSchemaFormJSONSchema 2 | 3 | 4 | __all__ = ("ReactJsonSchemaFormJSONSchema",) 5 | -------------------------------------------------------------------------------- /marshmallow_jsonschema/extensions/react_jsonschema_form.py: -------------------------------------------------------------------------------- 1 | from marshmallow_jsonschema.base import JSONSchema 2 | 3 | 4 | class ReactJsonSchemaFormJSONSchema(JSONSchema): 5 | """ 6 | Usage (assuming marshmallow v3): 7 | 8 | class MySchema(Schema): 9 | first_name = fields.String( 10 | metadata={ 11 | 'ui:autofocus': True, 12 | } 13 | ) 14 | last_name = fields.String() 15 | 16 | class Meta: 17 | react_uischema_extra = { 18 | 'ui:order': [ 19 | 'first_name', 20 | 'last_name', 21 | ] 22 | } 23 | 24 | 25 | json_schema_obj = ReactJsonSchemaFormJSONSchema() 26 | json_schema, uischema = json_schema_obj.dump_with_uischema(MySchema()) 27 | """ 28 | 29 | def dump_with_uischema(self, obj, many=None, *args): 30 | """Runs both dump and dump_uischema""" 31 | dump = self.dump(obj, *args, many=many) 32 | uischema = self.dump_uischema(obj, *args, many=many) 33 | return dump, uischema 34 | 35 | def dump_uischema(self, obj, many=None, *args): 36 | """ 37 | Attempt to return something resembling a uiSchema compliant with 38 | react-jsonschema-form 39 | 40 | See: https://react-jsonschema-form.readthedocs.io/en/latest/form-customization/#the-uischema-object 41 | """ 42 | return dict(self._dump_uischema_iter(obj, *args, many=many)) 43 | 44 | def _dump_uischema_iter(self, obj, many=None, *args): 45 | """ 46 | This is simply implementing a Dictionary Iterator for 47 | ReactJsonSchemaFormJSONSchema.dump_uischema 48 | """ 49 | 50 | for k, v in getattr(obj.Meta, "react_uischema_extra", {}).items(): 51 | yield k, v 52 | 53 | for field_name, field in obj.fields.items(): 54 | # NOTE: doubled up to maintain backwards compatibility 55 | metadata = field.metadata.get("metadata", {}) 56 | metadata.update(field.metadata) 57 | yield field_name, {k: v for k, v in metadata.items() if k.startswith("ui:")} 58 | -------------------------------------------------------------------------------- /marshmallow_jsonschema/validation.py: -------------------------------------------------------------------------------- 1 | from marshmallow import fields 2 | 3 | from .exceptions import UnsupportedValueError 4 | 5 | 6 | def handle_length(schema, field, validator, parent_schema): 7 | """Adds validation logic for ``marshmallow.validate.Length``, setting the 8 | values appropriately for ``fields.List``, ``fields.Nested``, and 9 | ``fields.String``. 10 | 11 | Args: 12 | schema (dict): The original JSON schema we generated. This is what we 13 | want to post-process. 14 | field (fields.Field): The field that generated the original schema and 15 | who this post-processor belongs to. 16 | validator (marshmallow.validate.Length): The validator attached to the 17 | passed in field. 18 | parent_schema (marshmallow.Schema): The Schema instance that the field 19 | belongs to. 20 | 21 | Returns: 22 | dict: A, possibly, new JSON Schema that has been post processed and 23 | altered. 24 | 25 | Raises: 26 | UnsupportedValueError: Raised if the `field` is something other than 27 | `fields.List`, `fields.Nested`, or `fields.String` 28 | """ 29 | if isinstance(field, fields.String): 30 | minKey = "minLength" 31 | maxKey = "maxLength" 32 | elif isinstance(field, (fields.List, fields.Nested)): 33 | minKey = "minItems" 34 | maxKey = "maxItems" 35 | else: 36 | raise UnsupportedValueError( 37 | "In order to set the Length validator for JSON " 38 | "schema, the field must be either a List, Nested or a String" 39 | ) 40 | 41 | if validator.min: 42 | schema[minKey] = validator.min 43 | 44 | if validator.max: 45 | schema[maxKey] = validator.max 46 | 47 | if validator.equal: 48 | schema[minKey] = validator.equal 49 | schema[maxKey] = validator.equal 50 | 51 | return schema 52 | 53 | 54 | def handle_one_of(schema, field, validator, parent_schema): 55 | """Adds the validation logic for ``marshmallow.validate.OneOf`` by setting 56 | the JSONSchema `enum` property to the allowed choices in the validator. 57 | 58 | Args: 59 | schema (dict): The original JSON schema we generated. This is what we 60 | want to post-process. 61 | field (fields.Field): The field that generated the original schema and 62 | who this post-processor belongs to. 63 | validator (marshmallow.validate.OneOf): The validator attached to the 64 | passed in field. 65 | parent_schema (marshmallow.Schema): The Schema instance that the field 66 | belongs to. 67 | 68 | Returns: 69 | dict: New JSON Schema that has been post processed and 70 | altered. 71 | """ 72 | schema["enum"] = list(validator.choices) 73 | schema["enumNames"] = list(validator.labels) 74 | 75 | return schema 76 | 77 | 78 | def handle_equal(schema, field, validator, parent_schema): 79 | """Adds the validation logic for ``marshmallow.validate.Equal`` by setting 80 | the JSONSchema `enum` property to value of the validator. 81 | 82 | Args: 83 | schema (dict): The original JSON schema we generated. This is what we 84 | want to post-process. 85 | field (fields.Field): The field that generated the original schema and 86 | who this post-processor belongs to. 87 | validator (marshmallow.validate.Equal): The validator attached to the 88 | passed in field. 89 | parent_schema (marshmallow.Schema): The Schema instance that the field 90 | belongs to. 91 | 92 | Returns: 93 | dict: New JSON Schema that has been post processed and 94 | altered. 95 | """ 96 | # Deliberately using `enum` instead of `const` for increased compatibility. 97 | # 98 | # https://json-schema.org/understanding-json-schema/reference/generic.html#constant-values 99 | # It should be noted that const is merely syntactic sugar for an enum with a single element [...] 100 | schema["enum"] = [validator.comparable] 101 | 102 | return schema 103 | 104 | 105 | def handle_range(schema, field, validator, parent_schema): 106 | """Adds validation logic for ``marshmallow.validate.Range``, setting the 107 | values appropriately ``fields.Number`` and it's subclasses. 108 | 109 | Args: 110 | schema (dict): The original JSON schema we generated. This is what we 111 | want to post-process. 112 | field (fields.Field): The field that generated the original schema and 113 | who this post-processor belongs to. 114 | validator (marshmallow.validate.Range): The validator attached to the 115 | passed in field. 116 | parent_schema (marshmallow.Schema): The Schema instance that the field 117 | belongs to. 118 | 119 | Returns: 120 | dict: New JSON Schema that has been post processed and 121 | altered. 122 | 123 | Raises: 124 | UnsupportedValueError: Raised if the `field` is not an instance of 125 | `fields.Number`. 126 | """ 127 | if not isinstance(field, fields.Number): 128 | raise UnsupportedValueError( 129 | "'Range' validator for non-number fields is not supported" 130 | ) 131 | 132 | if validator.min is not None: 133 | # marshmallow 2 includes minimum by default 134 | # marshmallow 3 supports "min_inclusive" 135 | min_inclusive = getattr(validator, "min_inclusive", True) 136 | if min_inclusive: 137 | schema["minimum"] = validator.min 138 | else: 139 | schema["exclusiveMinimum"] = validator.min 140 | 141 | if validator.max is not None: 142 | # marshmallow 2 includes maximum by default 143 | # marshmallow 3 supports "max_inclusive" 144 | max_inclusive = getattr(validator, "max_inclusive", True) 145 | if max_inclusive: 146 | schema["maximum"] = validator.max 147 | else: 148 | schema["exclusiveMaximum"] = validator.max 149 | return schema 150 | 151 | 152 | def handle_regexp(schema, field, validator, parent_schema): 153 | """Adds validation logic for ``marshmallow.validate.Regexp``, setting the 154 | values appropriately ``fields.String`` and it's subclasses. 155 | 156 | Args: 157 | schema (dict): The original JSON schema we generated. This is what we 158 | want to post-process. 159 | field (fields.Field): The field that generated the original schema and 160 | who this post-processor belongs to. 161 | validator (marshmallow.validate.Regexp): The validator attached to the 162 | passed in field. 163 | parent_schema (marshmallow.Schema): The Schema instance that the field 164 | belongs to. 165 | 166 | Returns: 167 | dict: New JSON Schema that has been post processed and 168 | altered. 169 | 170 | Raises: 171 | UnsupportedValueError: Raised if the `field` is not an instance of 172 | `fields.String`. 173 | """ 174 | if not isinstance(field, fields.String): 175 | raise UnsupportedValueError( 176 | "'Regexp' validator for non-string fields is not supported" 177 | ) 178 | 179 | schema["pattern"] = validator.regex.pattern 180 | 181 | return schema 182 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ['py39', 'py310'] 3 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | coverage>=6.0.2 2 | jsonschema>=4 3 | pytest>=6.2.5 4 | pytest-cov 5 | # Optional installs for the wheel, but always required for tests 6 | marshmallow-enum 7 | marshmallow-union 8 | mypy>=1.1.1 9 | 10 | pre-commit~=2.15 11 | -------------------------------------------------------------------------------- /requirements-tox.txt: -------------------------------------------------------------------------------- 1 | tox>=3.24.4 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | marshmallow>=3.11 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | 4 | from setuptools import setup, find_packages 5 | 6 | 7 | PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) 8 | 9 | 10 | def read(fname): 11 | with io.open(fname) as fp: 12 | return fp.read() 13 | 14 | 15 | long_description = read("README.md") 16 | 17 | 18 | REQUIREMENTS_FILE = "requirements.txt" 19 | REQUIREMENTS = open(os.path.join(PROJECT_DIR, REQUIREMENTS_FILE)).readlines() 20 | 21 | REQUIREMENTS_TESTS_FILE = "requirements-test.txt" 22 | REQUIREMENTS_TESTS = open( 23 | os.path.join(PROJECT_DIR, REQUIREMENTS_TESTS_FILE) 24 | ).readlines() 25 | 26 | REQUIREMENTS_TOX_FILE = "requirements-tox.txt" 27 | REQUIREMENTS_TOX = open(os.path.join(PROJECT_DIR, REQUIREMENTS_TOX_FILE)).readlines() 28 | 29 | EXTRAS_REQUIRE = { 30 | "enum": ["marshmallow-enum"], 31 | "union": ["marshmallow-union"], 32 | } 33 | 34 | 35 | setup( 36 | name="marshmallow-jsonschema", 37 | version="0.13.0", 38 | description="JSON Schema Draft v7 (http://json-schema.org/)" 39 | " formatting with marshmallow", 40 | long_description=long_description, 41 | long_description_content_type="text/markdown", 42 | author="Stephen Fuhry", 43 | author_email="fuhrysteve@gmail.com", 44 | url="https://github.com/fuhrysteve/marshmallow-jsonschema", 45 | packages=find_packages(exclude=("test*",)), 46 | package_dir={"marshmallow-jsonschema": "marshmallow-jsonschema"}, 47 | include_package_data=True, 48 | install_requires=REQUIREMENTS, 49 | tests_require=REQUIREMENTS_TESTS + REQUIREMENTS_TOX, 50 | extras_require=EXTRAS_REQUIRE, 51 | license="MIT License", 52 | zip_safe=False, 53 | keywords=( 54 | "marshmallow-jsonschema marshmallow schema serialization " 55 | "jsonschema validation" 56 | ), 57 | python_requires=">=3.6", 58 | classifiers=[ 59 | "Intended Audience :: Developers", 60 | "License :: OSI Approved :: MIT License", 61 | "Natural Language :: English", 62 | "Programming Language :: Python :: 3", 63 | "Programming Language :: Python :: 3.6", 64 | "Programming Language :: Python :: 3.7", 65 | "Programming Language :: Python :: 3.8", 66 | "Programming Language :: Python :: 3.9", 67 | "Programming Language :: Python :: 3.10", 68 | ], 69 | test_suite="tests", 70 | ) 71 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from jsonschema import Draft7Validator 2 | from marshmallow import Schema, fields, validate 3 | 4 | from marshmallow_jsonschema import JSONSchema 5 | 6 | 7 | class Address(Schema): 8 | id = fields.String(default="no-id") 9 | street = fields.String(required=True) 10 | number = fields.String(required=True) 11 | city = fields.String(required=True) 12 | floor = fields.Integer(validate=validate.Range(min=1, max=4)) 13 | 14 | 15 | class GithubProfile(Schema): 16 | uri = fields.String(required=True) 17 | 18 | 19 | class UserSchema(Schema): 20 | name = fields.String(required=True, validate=validate.Length(min=1, max=255)) 21 | age = fields.Float() 22 | created = fields.DateTime() 23 | created_formatted = fields.DateTime( 24 | format="%Y-%m-%d", attribute="created", dump_only=True 25 | ) 26 | created_iso = fields.DateTime(format="iso", attribute="created", dump_only=True) 27 | updated_naive = fields.NaiveDateTime(attribute="updated", dump_only=True) 28 | updated = fields.DateTime() 29 | species = fields.String(attribute="SPECIES") 30 | id = fields.String(default="no-id") 31 | homepage = fields.Url() 32 | email = fields.Email() 33 | balance = fields.Decimal() 34 | registered = fields.Boolean() 35 | hair_colors = fields.List(fields.Raw) 36 | sex_choices = fields.List(fields.Raw) 37 | finger_count = fields.Integer() 38 | uid = fields.UUID() 39 | time_registered = fields.Time() 40 | birthdate = fields.Date() 41 | since_created = fields.TimeDelta() 42 | sex = fields.Str( 43 | validate=validate.OneOf( 44 | choices=["male", "female", "non_binary", "other"], 45 | labels=["Male", "Female", "Non-binary/fluid", "Other"], 46 | ) 47 | ) 48 | various_data = fields.Dict() 49 | addresses = fields.Nested( 50 | Address, many=True, validate=validate.Length(min=1, max=3) 51 | ) 52 | github = fields.Nested(GithubProfile) 53 | const = fields.String(validate=validate.Length(equal=50)) 54 | is_user = fields.Boolean(validate=validate.Equal(True)) 55 | 56 | 57 | def _validate_schema(schema): 58 | """ 59 | raises jsonschema.exceptions.SchemaError 60 | """ 61 | Draft7Validator.check_schema(schema) 62 | 63 | 64 | def validate_and_dump(schema): 65 | json_schema = JSONSchema() 66 | data = json_schema.dump(schema) 67 | _validate_schema(data) 68 | # ensure last version 69 | assert data["$schema"] == "http://json-schema.org/draft-07/schema#" 70 | return data 71 | -------------------------------------------------------------------------------- /tests/test_additional_properties.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from marshmallow import Schema, fields, RAISE, INCLUDE, EXCLUDE 3 | 4 | from marshmallow_jsonschema import UnsupportedValueError, JSONSchema 5 | from . import validate_and_dump 6 | 7 | 8 | def test_additional_properties_default(): 9 | class TestSchema(Schema): 10 | foo = fields.Integer() 11 | 12 | schema = TestSchema() 13 | 14 | dumped = validate_and_dump(schema) 15 | 16 | assert not dumped["definitions"]["TestSchema"]["additionalProperties"] 17 | 18 | 19 | @pytest.mark.parametrize("additional_properties_value", (False, True)) 20 | def test_additional_properties_from_meta(additional_properties_value): 21 | class TestSchema(Schema): 22 | class Meta: 23 | additional_properties = additional_properties_value 24 | 25 | foo = fields.Integer() 26 | 27 | schema = TestSchema() 28 | 29 | dumped = validate_and_dump(schema) 30 | 31 | assert ( 32 | dumped["definitions"]["TestSchema"]["additionalProperties"] 33 | == additional_properties_value 34 | ) 35 | 36 | 37 | def test_additional_properties_invalid_value(): 38 | class TestSchema(Schema): 39 | class Meta: 40 | additional_properties = "foo" 41 | 42 | foo = fields.Integer() 43 | 44 | schema = TestSchema() 45 | json_schema = JSONSchema() 46 | 47 | with pytest.raises(UnsupportedValueError): 48 | json_schema.dump(schema) 49 | 50 | 51 | def test_additional_properties_nested_default(): 52 | class TestNestedSchema(Schema): 53 | foo = fields.Integer() 54 | 55 | class TestSchema(Schema): 56 | nested = fields.Nested(TestNestedSchema()) 57 | 58 | schema = TestSchema() 59 | 60 | dumped = validate_and_dump(schema) 61 | 62 | assert not dumped["definitions"]["TestSchema"]["additionalProperties"] 63 | 64 | 65 | @pytest.mark.parametrize("additional_properties_value", (False, True)) 66 | def test_additional_properties_from_nested_meta(additional_properties_value): 67 | class TestNestedSchema(Schema): 68 | class Meta: 69 | additional_properties = additional_properties_value 70 | 71 | foo = fields.Integer() 72 | 73 | class TestSchema(Schema): 74 | nested = fields.Nested(TestNestedSchema()) 75 | 76 | schema = TestSchema() 77 | 78 | dumped = validate_and_dump(schema) 79 | 80 | assert ( 81 | dumped["definitions"]["TestNestedSchema"]["additionalProperties"] 82 | == additional_properties_value 83 | ) 84 | 85 | 86 | @pytest.mark.parametrize( 87 | "unknown_value, additional_properties", 88 | ((RAISE, False), (INCLUDE, True), (EXCLUDE, False)), 89 | ) 90 | def test_additional_properties_deduced(unknown_value, additional_properties): 91 | class TestSchema(Schema): 92 | class Meta: 93 | unknown = unknown_value 94 | 95 | foo = fields.Integer() 96 | 97 | schema = TestSchema() 98 | 99 | dumped = validate_and_dump(schema) 100 | 101 | assert ( 102 | dumped["definitions"]["TestSchema"]["additionalProperties"] 103 | == additional_properties 104 | ) 105 | -------------------------------------------------------------------------------- /tests/test_dump.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from enum import Enum 3 | 4 | import pytest 5 | from marshmallow import Schema, fields, validate 6 | from marshmallow_enum import EnumField 7 | from marshmallow_union import Union 8 | 9 | from marshmallow_jsonschema import JSONSchema, UnsupportedValueError 10 | from . import UserSchema, validate_and_dump 11 | 12 | 13 | def test_dump_schema(): 14 | schema = UserSchema() 15 | 16 | dumped = validate_and_dump(schema) 17 | 18 | assert len(schema.fields) > 1 19 | 20 | props = dumped["definitions"]["UserSchema"]["properties"] 21 | for field_name, field in schema.fields.items(): 22 | assert field_name in props 23 | 24 | 25 | def test_default(): 26 | schema = UserSchema() 27 | 28 | dumped = validate_and_dump(schema) 29 | 30 | props = dumped["definitions"]["UserSchema"]["properties"] 31 | assert props["id"]["default"] == "no-id" 32 | 33 | 34 | def test_default_callable_not_serialized(): 35 | class TestSchema(Schema): 36 | uid = fields.UUID(default=uuid.uuid4) 37 | 38 | schema = TestSchema() 39 | 40 | dumped = validate_and_dump(schema) 41 | 42 | props = dumped["definitions"]["TestSchema"]["properties"] 43 | assert "default" not in props["uid"] 44 | 45 | 46 | def test_uuid(): 47 | schema = UserSchema() 48 | 49 | dumped = validate_and_dump(schema) 50 | 51 | props = dumped["definitions"]["UserSchema"]["properties"] 52 | assert props["uid"]["type"] == "string" 53 | assert props["uid"]["format"] == "uuid" 54 | 55 | 56 | def test_metadata(): 57 | """Metadata should be available in the field definition.""" 58 | 59 | class TestSchema(Schema): 60 | myfield = fields.String(metadata={"foo": "Bar"}) 61 | yourfield = fields.Integer(required=True, baz="waz") 62 | 63 | schema = TestSchema() 64 | 65 | dumped = validate_and_dump(schema) 66 | 67 | props = dumped["definitions"]["TestSchema"]["properties"] 68 | assert props["myfield"]["foo"] == "Bar" 69 | assert props["yourfield"]["baz"] == "waz" 70 | assert "metadata" not in props["myfield"] 71 | assert "metadata" not in props["yourfield"] 72 | 73 | # repeat process to assert idempotency 74 | dumped = validate_and_dump(schema) 75 | 76 | props = dumped["definitions"]["TestSchema"]["properties"] 77 | assert props["myfield"]["foo"] == "Bar" 78 | assert props["yourfield"]["baz"] == "waz" 79 | 80 | 81 | def test_descriptions(): 82 | class TestSchema(Schema): 83 | myfield = fields.String(metadata={"description": "Brown Cow"}) 84 | yourfield = fields.Integer(required=True) 85 | 86 | schema = TestSchema() 87 | 88 | dumped = validate_and_dump(schema) 89 | 90 | props = dumped["definitions"]["TestSchema"]["properties"] 91 | assert props["myfield"]["description"] == "Brown Cow" 92 | 93 | 94 | def test_nested_descriptions(): 95 | class TestNestedSchema(Schema): 96 | myfield = fields.String(metadata={"description": "Brown Cow"}) 97 | yourfield = fields.Integer(required=True) 98 | 99 | class TestSchema(Schema): 100 | nested = fields.Nested( 101 | TestNestedSchema, metadata={"description": "Nested 1", "title": "Title1"} 102 | ) 103 | yourfield_nested = fields.Integer(required=True) 104 | 105 | schema = TestSchema() 106 | 107 | dumped = validate_and_dump(schema) 108 | 109 | nested_def = dumped["definitions"]["TestNestedSchema"] 110 | nested_dmp = dumped["definitions"]["TestSchema"]["properties"]["nested"] 111 | assert nested_def["properties"]["myfield"]["description"] == "Brown Cow" 112 | 113 | assert nested_dmp["$ref"] == "#/definitions/TestNestedSchema" 114 | assert nested_dmp["description"] == "Nested 1" 115 | assert nested_dmp["title"] == "Title1" 116 | 117 | 118 | def test_nested_string_to_cls(): 119 | class TestNamedNestedSchema(Schema): 120 | foo = fields.Integer(required=True) 121 | 122 | class TestSchema(Schema): 123 | foo2 = fields.Integer(required=True) 124 | nested = fields.Nested("TestNamedNestedSchema") 125 | 126 | schema = TestSchema() 127 | 128 | dumped = validate_and_dump(schema) 129 | 130 | nested_def = dumped["definitions"]["TestNamedNestedSchema"] 131 | nested_dmp = dumped["definitions"]["TestSchema"]["properties"]["nested"] 132 | assert nested_dmp["type"] == "object" 133 | assert nested_def["properties"]["foo"]["type"] == "integer" 134 | 135 | 136 | def test_nested_context(): 137 | class TestNestedSchema(Schema): 138 | def __init__(self, *args, **kwargs): 139 | if kwargs.get("context", {}).get("hide", False): 140 | kwargs["exclude"] = ["foo"] 141 | super().__init__(*args, **kwargs) 142 | 143 | foo = fields.Integer(required=True) 144 | bar = fields.Integer(required=True) 145 | 146 | class TestSchema(Schema): 147 | bar = fields.Nested(TestNestedSchema) 148 | 149 | schema = TestSchema() 150 | dumped_show = validate_and_dump(schema) 151 | 152 | schema = TestSchema(context={"hide": True}) 153 | dumped_hide = validate_and_dump(schema) 154 | 155 | nested_show = dumped_show["definitions"]["TestNestedSchema"]["properties"] 156 | nested_hide = dumped_hide["definitions"]["TestNestedSchema"]["properties"] 157 | 158 | assert "bar" in nested_show and "foo" in nested_show 159 | assert "bar" in nested_hide and "foo" not in nested_hide 160 | 161 | 162 | def test_list(): 163 | class ListSchema(Schema): 164 | foo = fields.List(fields.String(), required=True) 165 | 166 | schema = ListSchema() 167 | dumped = validate_and_dump(schema) 168 | 169 | nested_json = dumped["definitions"]["ListSchema"]["properties"]["foo"] 170 | assert nested_json["type"] == "array" 171 | assert "items" in nested_json 172 | 173 | item_schema = nested_json["items"] 174 | assert item_schema["type"] == "string" 175 | 176 | 177 | def test_list_nested(): 178 | """Test that a list field will work with an inner nested field.""" 179 | 180 | class InnerSchema(Schema): 181 | foo = fields.Integer(required=True) 182 | 183 | class ListSchema(Schema): 184 | bar = fields.List(fields.Nested(InnerSchema), required=True) 185 | 186 | schema = ListSchema() 187 | dumped = validate_and_dump(schema) 188 | 189 | nested_json = dumped["definitions"]["ListSchema"]["properties"]["bar"] 190 | 191 | assert nested_json["type"] == "array" 192 | assert "items" in nested_json 193 | 194 | item_schema = nested_json["items"] 195 | assert "InnerSchema" in item_schema["$ref"] 196 | 197 | 198 | def test_dict(): 199 | class DictSchema(Schema): 200 | foo = fields.Dict() 201 | 202 | schema = DictSchema() 203 | dumped = validate_and_dump(schema) 204 | 205 | nested_json = dumped["definitions"]["DictSchema"]["properties"]["foo"] 206 | 207 | assert nested_json["type"] == "object" 208 | assert "additionalProperties" in nested_json 209 | 210 | item_schema = nested_json["additionalProperties"] 211 | assert item_schema == {} 212 | 213 | 214 | def test_dict_with_value_field(): 215 | class DictSchema(Schema): 216 | foo = fields.Dict(keys=fields.String, values=fields.Integer) 217 | 218 | schema = DictSchema() 219 | dumped = validate_and_dump(schema) 220 | 221 | nested_json = dumped["definitions"]["DictSchema"]["properties"]["foo"] 222 | 223 | assert nested_json["type"] == "object" 224 | assert "additionalProperties" in nested_json 225 | 226 | item_schema = nested_json["additionalProperties"] 227 | assert item_schema["type"] == "integer" 228 | 229 | 230 | def test_dict_with_nested_value_field(): 231 | class InnerSchema(Schema): 232 | foo = fields.Integer(required=True) 233 | 234 | class DictSchema(Schema): 235 | bar = fields.Dict(keys=fields.String, values=fields.Nested(InnerSchema)) 236 | 237 | schema = DictSchema() 238 | dumped = validate_and_dump(schema) 239 | 240 | nested_json = dumped["definitions"]["DictSchema"]["properties"]["bar"] 241 | 242 | assert nested_json["type"] == "object" 243 | assert "additionalProperties" in nested_json 244 | 245 | item_schema = nested_json["additionalProperties"] 246 | assert item_schema["type"] == "object" 247 | 248 | assert "InnerSchema" in item_schema["$ref"] 249 | 250 | 251 | def test_deep_nested(): 252 | """Test that deep nested schemas are in definitions.""" 253 | 254 | class InnerSchema(Schema): 255 | boz = fields.Integer(required=True) 256 | 257 | class InnerMiddleSchema(Schema): 258 | baz = fields.Nested(InnerSchema, required=True) 259 | 260 | class OuterMiddleSchema(Schema): 261 | bar = fields.Nested(InnerMiddleSchema, required=True) 262 | 263 | class OuterSchema(Schema): 264 | foo = fields.Nested(OuterMiddleSchema, required=True) 265 | 266 | schema = OuterSchema() 267 | dumped = validate_and_dump(schema) 268 | 269 | defs = dumped["definitions"] 270 | assert "OuterSchema" in defs 271 | assert "OuterMiddleSchema" in defs 272 | assert "InnerMiddleSchema" in defs 273 | assert "InnerSchema" in defs 274 | 275 | 276 | def test_respect_only_for_nested_schema(): 277 | """Should ignore fields not in 'only' metadata for nested schemas.""" 278 | 279 | class InnerRecursiveSchema(Schema): 280 | id = fields.Integer(required=True) 281 | baz = fields.String() 282 | recursive = fields.Nested("InnerRecursiveSchema") 283 | 284 | class MiddleSchema(Schema): 285 | id = fields.Integer(required=True) 286 | bar = fields.String() 287 | inner = fields.Nested("InnerRecursiveSchema", only=("id", "baz")) 288 | 289 | class OuterSchema(Schema): 290 | foo2 = fields.Integer(required=True) 291 | nested = fields.Nested("MiddleSchema") 292 | 293 | schema = OuterSchema() 294 | dumped = validate_and_dump(schema) 295 | inner_props = dumped["definitions"]["InnerRecursiveSchema"]["properties"] 296 | assert "recursive" not in inner_props 297 | 298 | 299 | def test_respect_exclude_for_nested_schema(): 300 | """Should ignore fields in 'exclude' metadata for nested schemas.""" 301 | 302 | class InnerRecursiveSchema(Schema): 303 | id = fields.Integer(required=True) 304 | baz = fields.String() 305 | recursive = fields.Nested("InnerRecursiveSchema") 306 | 307 | class MiddleSchema(Schema): 308 | id = fields.Integer(required=True) 309 | bar = fields.String() 310 | inner = fields.Nested("InnerRecursiveSchema", exclude=("recursive",)) 311 | 312 | class OuterSchema(Schema): 313 | foo2 = fields.Integer(required=True) 314 | nested = fields.Nested("MiddleSchema") 315 | 316 | schema = OuterSchema() 317 | 318 | dumped = validate_and_dump(schema) 319 | 320 | inner_props = dumped["definitions"]["InnerRecursiveSchema"]["properties"] 321 | assert "recursive" not in inner_props 322 | 323 | 324 | def test_respect_dotted_exclude_for_nested_schema(): 325 | """Should ignore dotted fields in 'exclude' metadata for nested schemas.""" 326 | 327 | class InnerRecursiveSchema(Schema): 328 | id = fields.Integer(required=True) 329 | baz = fields.String() 330 | recursive = fields.Nested("InnerRecursiveSchema") 331 | 332 | class MiddleSchema(Schema): 333 | id = fields.Integer(required=True) 334 | bar = fields.String() 335 | inner = fields.Nested("InnerRecursiveSchema") 336 | 337 | class OuterSchema(Schema): 338 | foo2 = fields.Integer(required=True) 339 | nested = fields.Nested("MiddleSchema", exclude=("inner.recursive",)) 340 | 341 | schema = OuterSchema() 342 | 343 | dumped = validate_and_dump(schema) 344 | 345 | inner_props = dumped["definitions"]["InnerRecursiveSchema"]["properties"] 346 | assert "recursive" not in inner_props 347 | 348 | 349 | def test_respect_default_for_nested_schema(): 350 | class TestNestedSchema(Schema): 351 | myfield = fields.String() 352 | yourfield = fields.Integer(required=True) 353 | 354 | nested_default = {"myfield": "myval", "yourfield": 1} 355 | 356 | class TestSchema(Schema): 357 | nested = fields.Nested( 358 | TestNestedSchema, 359 | default=nested_default, 360 | ) 361 | yourfield_nested = fields.Integer(required=True) 362 | 363 | schema = TestSchema() 364 | dumped = validate_and_dump(schema) 365 | default = dumped["definitions"]["TestSchema"]["properties"]["nested"]["default"] 366 | assert default == nested_default 367 | 368 | 369 | def test_nested_instance(): 370 | """Should also work with nested schema instances""" 371 | 372 | class TestNestedSchema(Schema): 373 | baz = fields.Integer() 374 | 375 | class TestSchema(Schema): 376 | foo = fields.String() 377 | bar = fields.Nested(TestNestedSchema()) 378 | 379 | schema = TestSchema() 380 | 381 | dumped = validate_and_dump(schema) 382 | 383 | nested_def = dumped["definitions"]["TestNestedSchema"] 384 | nested_obj = dumped["definitions"]["TestSchema"]["properties"]["bar"] 385 | 386 | assert "baz" in nested_def["properties"] 387 | assert nested_obj["$ref"] == "#/definitions/TestNestedSchema" 388 | 389 | 390 | def test_function(): 391 | """Function fields can be serialised if type is given.""" 392 | 393 | class FnSchema(Schema): 394 | fn_str = fields.Function( 395 | lambda: "string", required=True, _jsonschema_type_mapping={"type": "string"} 396 | ) 397 | fn_int = fields.Function( 398 | lambda: 123, required=True, _jsonschema_type_mapping={"type": "number"} 399 | ) 400 | 401 | schema = FnSchema() 402 | 403 | dumped = validate_and_dump(schema) 404 | 405 | props = dumped["definitions"]["FnSchema"]["properties"] 406 | assert props["fn_int"]["type"] == "number" 407 | assert props["fn_str"]["type"] == "string" 408 | 409 | 410 | def test_nested_recursive(): 411 | """A self-referential schema should not cause an infinite recurse.""" 412 | 413 | class RecursiveSchema(Schema): 414 | foo = fields.Integer(required=True) 415 | children = fields.Nested("RecursiveSchema", many=True) 416 | 417 | schema = RecursiveSchema() 418 | 419 | dumped = validate_and_dump(schema) 420 | 421 | props = dumped["definitions"]["RecursiveSchema"]["properties"] 422 | assert "RecursiveSchema" in props["children"]["items"]["$ref"] 423 | 424 | 425 | def test_title(): 426 | class TestSchema(Schema): 427 | myfield = fields.String(metadata={"title": "Brown Cowzz"}) 428 | yourfield = fields.Integer(required=True) 429 | 430 | schema = TestSchema() 431 | 432 | dumped = validate_and_dump(schema) 433 | 434 | assert ( 435 | dumped["definitions"]["TestSchema"]["properties"]["myfield"]["title"] 436 | == "Brown Cowzz" 437 | ) 438 | 439 | 440 | def test_unknown_typed_field_throws_valueerror(): 441 | class Invalid(fields.Field): 442 | def _serialize(self, value, attr, obj): 443 | return value 444 | 445 | class UserSchema(Schema): 446 | favourite_colour = Invalid() 447 | 448 | schema = UserSchema() 449 | json_schema = JSONSchema() 450 | 451 | with pytest.raises(UnsupportedValueError): 452 | validate_and_dump(json_schema.dump(schema)) 453 | 454 | 455 | def test_unknown_typed_field(): 456 | class Colour(fields.Field): 457 | def _jsonschema_type_mapping(self): 458 | return {"type": "string"} 459 | 460 | def _serialize(self, value, attr, obj): 461 | r, g, b = value 462 | r = hex(r)[2:] 463 | g = hex(g)[2:] 464 | b = hex(b)[2:] 465 | return "#" + r + g + b 466 | 467 | class UserSchema(Schema): 468 | name = fields.String(required=True) 469 | favourite_colour = Colour() 470 | 471 | schema = UserSchema() 472 | 473 | dumped = validate_and_dump(schema) 474 | 475 | assert dumped["definitions"]["UserSchema"]["properties"]["favourite_colour"] == { 476 | "type": "string" 477 | } 478 | 479 | 480 | def test_field_subclass(): 481 | """JSON schema generation should not fail on sublcass marshmallow field.""" 482 | 483 | class CustomField(fields.Field): 484 | pass 485 | 486 | class TestSchema(Schema): 487 | myfield = CustomField() 488 | 489 | schema = TestSchema() 490 | with pytest.raises(UnsupportedValueError): 491 | _ = validate_and_dump(schema) 492 | 493 | 494 | def test_readonly(): 495 | class TestSchema(Schema): 496 | id = fields.Integer(required=True) 497 | readonly_fld = fields.String(dump_only=True) 498 | 499 | schema = TestSchema() 500 | 501 | dumped = validate_and_dump(schema) 502 | 503 | assert dumped["definitions"]["TestSchema"]["properties"]["readonly_fld"] == { 504 | "title": "readonly_fld", 505 | "type": "string", 506 | "readOnly": True, 507 | } 508 | 509 | 510 | def test_metadata_direct_from_field(): 511 | """Should be able to get metadata without accessing metadata kwarg.""" 512 | 513 | class TestSchema(Schema): 514 | id = fields.Integer(required=True) 515 | metadata_field = fields.String(description="Directly on the field!") 516 | 517 | schema = TestSchema() 518 | 519 | dumped = validate_and_dump(schema) 520 | 521 | assert dumped["definitions"]["TestSchema"]["properties"]["metadata_field"] == { 522 | "title": "metadata_field", 523 | "type": "string", 524 | "description": "Directly on the field!", 525 | } 526 | 527 | 528 | def test_allow_none(): 529 | """A field with allow_none set to True should have type null as additional.""" 530 | 531 | class TestSchema(Schema): 532 | id = fields.Integer(required=True) 533 | readonly_fld = fields.String(allow_none=True) 534 | 535 | schema = TestSchema() 536 | 537 | dumped = validate_and_dump(schema) 538 | 539 | assert dumped["definitions"]["TestSchema"]["properties"]["readonly_fld"] == { 540 | "title": "readonly_fld", 541 | "type": ["string", "null"], 542 | } 543 | 544 | 545 | def test_dumps_iterable_enums(): 546 | mapping = {"a": 0, "b": 1, "c": 2} 547 | 548 | class TestSchema(Schema): 549 | foo = fields.Integer( 550 | validate=validate.OneOf(mapping.values(), labels=mapping.keys()) 551 | ) 552 | 553 | schema = TestSchema() 554 | 555 | dumped = validate_and_dump(schema) 556 | 557 | assert dumped["definitions"]["TestSchema"]["properties"]["foo"] == { 558 | "enum": [v for v in mapping.values()], 559 | "enumNames": [k for k in mapping.keys()], 560 | "title": "foo", 561 | "type": "integer", 562 | } 563 | 564 | 565 | def test_required_excluded_when_empty(): 566 | class TestSchema(Schema): 567 | optional_value = fields.String() 568 | 569 | schema = TestSchema() 570 | 571 | dumped = validate_and_dump(schema) 572 | 573 | assert "required" not in dumped["definitions"]["TestSchema"] 574 | 575 | 576 | def test_required_uses_data_key(): 577 | class TestSchema(Schema): 578 | optional_value = fields.String(data_key="opt", required=True) 579 | 580 | schema = TestSchema() 581 | 582 | dumped = validate_and_dump(schema) 583 | 584 | test_schema_definition = dumped["definitions"]["TestSchema"] 585 | assert "opt" in test_schema_definition["properties"] 586 | assert "optional_value" == test_schema_definition["properties"]["opt"]["title"] 587 | assert "required" in test_schema_definition 588 | assert "opt" in test_schema_definition["required"] 589 | 590 | 591 | def test_datetime_based(): 592 | class TestSchema(Schema): 593 | f_date = fields.Date() 594 | f_datetime = fields.DateTime() 595 | f_time = fields.Time() 596 | 597 | schema = TestSchema() 598 | 599 | dumped = validate_and_dump(schema) 600 | 601 | assert dumped["definitions"]["TestSchema"]["properties"]["f_date"] == { 602 | "format": "date", 603 | "title": "f_date", 604 | "type": "string", 605 | } 606 | 607 | assert dumped["definitions"]["TestSchema"]["properties"]["f_datetime"] == { 608 | "format": "date-time", 609 | "title": "f_datetime", 610 | "type": "string", 611 | } 612 | 613 | assert dumped["definitions"]["TestSchema"]["properties"]["f_time"] == { 614 | "format": "time", 615 | "title": "f_time", 616 | "type": "string", 617 | } 618 | 619 | 620 | def test_sorting_properties(): 621 | class TestSchema(Schema): 622 | class Meta: 623 | ordered = True 624 | 625 | d = fields.Str() 626 | c = fields.Str() 627 | a = fields.Str() 628 | 629 | # Should be sorting of fields 630 | schema = TestSchema() 631 | 632 | json_schema = JSONSchema() 633 | data = json_schema.dump(schema) 634 | 635 | sorted_keys = sorted(data["definitions"]["TestSchema"]["properties"].keys()) 636 | properties_names = [k for k in sorted_keys] 637 | assert properties_names == ["a", "c", "d"] 638 | 639 | # Should be saving ordering of fields 640 | schema = TestSchema() 641 | 642 | json_schema = JSONSchema(props_ordered=True) 643 | data = json_schema.dump(schema) 644 | 645 | keys = data["definitions"]["TestSchema"]["properties"].keys() 646 | properties_names = [k for k in keys] 647 | 648 | assert properties_names == ["d", "c", "a"] 649 | 650 | 651 | def test_enum_based(): 652 | class TestEnum(Enum): 653 | value_1 = 0 654 | value_2 = 1 655 | value_3 = 2 656 | 657 | class TestSchema(Schema): 658 | enum_prop = EnumField(TestEnum) 659 | 660 | # Should be sorting of fields 661 | schema = TestSchema() 662 | 663 | json_schema = JSONSchema() 664 | data = json_schema.dump(schema) 665 | 666 | assert ( 667 | data["definitions"]["TestSchema"]["properties"]["enum_prop"]["type"] == "string" 668 | ) 669 | received_enum_values = sorted( 670 | data["definitions"]["TestSchema"]["properties"]["enum_prop"]["enum"] 671 | ) 672 | assert received_enum_values == ["value_1", "value_2", "value_3"] 673 | 674 | 675 | def test_enum_based_load_dump_value(): 676 | class TestEnum(Enum): 677 | value_1 = 0 678 | value_2 = 1 679 | value_3 = 2 680 | 681 | class TestSchema(Schema): 682 | enum_prop = EnumField(TestEnum, by_value=True) 683 | 684 | # Should be sorting of fields 685 | schema = TestSchema() 686 | 687 | json_schema = JSONSchema() 688 | 689 | with pytest.raises(NotImplementedError): 690 | validate_and_dump(json_schema.dump(schema)) 691 | 692 | 693 | def test_union_based(): 694 | class TestNestedSchema(Schema): 695 | field_1 = fields.String() 696 | field_2 = fields.Integer() 697 | 698 | class TestSchema(Schema): 699 | union_prop = Union( 700 | [fields.String(), fields.Integer(), fields.Nested(TestNestedSchema)] 701 | ) 702 | 703 | # Should be sorting of fields 704 | schema = TestSchema() 705 | 706 | json_schema = JSONSchema() 707 | data = json_schema.dump(schema) 708 | 709 | # Expect only the `anyOf` key 710 | assert "anyOf" in data["definitions"]["TestSchema"]["properties"]["union_prop"] 711 | assert len(data["definitions"]["TestSchema"]["properties"]["union_prop"]) == 1 712 | 713 | string_schema = {"type": "string", "title": ""} 714 | integer_schema = {"type": "string", "title": ""} 715 | referenced_nested_schema = { 716 | "type": "object", 717 | "$ref": "#/definitions/TestNestedSchema", 718 | } 719 | actual_nested_schema = { 720 | "type": "object", 721 | "properties": { 722 | "field_1": {"type": "string", "title": "field_1"}, 723 | "field_2": {"type": "integer", "title": "field_2"}, 724 | }, 725 | "additionalProperties": False, 726 | } 727 | 728 | assert ( 729 | string_schema 730 | in data["definitions"]["TestSchema"]["properties"]["union_prop"]["anyOf"] 731 | ) 732 | assert ( 733 | integer_schema 734 | in data["definitions"]["TestSchema"]["properties"]["union_prop"]["anyOf"] 735 | ) 736 | assert ( 737 | referenced_nested_schema 738 | in data["definitions"]["TestSchema"]["properties"]["union_prop"]["anyOf"] 739 | ) 740 | 741 | assert data["definitions"]["TestNestedSchema"] == actual_nested_schema 742 | 743 | # Expect three possible schemas for the union type 744 | assert ( 745 | len(data["definitions"]["TestSchema"]["properties"]["union_prop"]["anyOf"]) == 3 746 | ) 747 | 748 | 749 | def test_dumping_recursive_schema(): 750 | """ 751 | this reproduces issue https://github.com/fuhrysteve/marshmallow-jsonschema/issues/164 752 | """ 753 | json_schema = JSONSchema() 754 | 755 | def generate_recursive_schema_with_name(): 756 | class RecursiveSchema(Schema): 757 | # when nesting recursively you can either refer the recursive schema by its name 758 | nested_mwe_recursive = fields.Nested("RecursiveSchema") 759 | 760 | return json_schema.dump(RecursiveSchema()) 761 | 762 | def generate_recursive_schema_with_lambda(): 763 | class RecursiveSchema(Schema): 764 | # or you can use a lambda (as suggested in the marshmallow docs) 765 | nested_mwe_recursive = fields.Nested(lambda: RecursiveSchema()) 766 | 767 | return json_schema.dump( 768 | RecursiveSchema() 769 | ) # this shall _not_ raise an AttributeError 770 | 771 | lambda_schema = generate_recursive_schema_with_lambda() 772 | name_schema = generate_recursive_schema_with_name() 773 | assert lambda_schema == name_schema 774 | -------------------------------------------------------------------------------- /tests/test_imports.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import marshmallow_jsonschema 3 | 4 | 5 | def test_import_marshmallow_union(monkeypatch): 6 | monkeypatch.delattr("marshmallow_union.Union") 7 | 8 | base = importlib.reload(marshmallow_jsonschema.base) 9 | 10 | assert not base.ALLOW_UNIONS 11 | 12 | monkeypatch.undo() 13 | 14 | importlib.reload(marshmallow_jsonschema.base) 15 | 16 | 17 | def test_import_marshmallow_enum(monkeypatch): 18 | monkeypatch.delattr("marshmallow_enum.EnumField") 19 | 20 | base = importlib.reload(marshmallow_jsonschema.base) 21 | 22 | assert not base.ALLOW_ENUMS 23 | 24 | monkeypatch.undo() 25 | 26 | importlib.reload(marshmallow_jsonschema.base) 27 | -------------------------------------------------------------------------------- /tests/test_react_extension.py: -------------------------------------------------------------------------------- 1 | import marshmallow as ma 2 | 3 | from marshmallow_jsonschema.extensions import ReactJsonSchemaFormJSONSchema 4 | 5 | 6 | class MySchema(ma.Schema): 7 | first_name = ma.fields.String(metadata={"ui:autofocus": True}) 8 | last_name = ma.fields.String() 9 | 10 | class Meta: 11 | react_uischema_extra = {"ui:order": ["first_name", "last_name"]} 12 | 13 | 14 | def test_can_dump_react_jsonschema_form(): 15 | json_schema_obj = ReactJsonSchemaFormJSONSchema() 16 | json_schema, uischema = json_schema_obj.dump_with_uischema(MySchema()) 17 | assert uischema == { 18 | "first_name": {"ui:autofocus": True}, 19 | "last_name": {}, 20 | "ui:order": ["first_name", "last_name"], 21 | } 22 | -------------------------------------------------------------------------------- /tests/test_validation.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import pytest 4 | from marshmallow import Schema, fields, validate 5 | from marshmallow.validate import OneOf, Range 6 | from marshmallow_enum import EnumField 7 | from marshmallow_union import Union 8 | 9 | from marshmallow_jsonschema import JSONSchema, UnsupportedValueError 10 | from . import UserSchema, validate_and_dump 11 | 12 | 13 | def test_equal_validator(): 14 | schema = UserSchema() 15 | 16 | dumped = validate_and_dump(schema) 17 | 18 | assert dumped["definitions"]["UserSchema"]["properties"]["is_user"]["enum"] == [ 19 | True 20 | ] 21 | 22 | 23 | def test_length_validator(): 24 | schema = UserSchema() 25 | 26 | dumped = validate_and_dump(schema) 27 | 28 | props = dumped["definitions"]["UserSchema"]["properties"] 29 | assert props["name"]["minLength"] == 1 30 | assert props["name"]["maxLength"] == 255 31 | assert props["addresses"]["minItems"] == 1 32 | assert props["addresses"]["maxItems"] == 3 33 | assert props["const"]["minLength"] == 50 34 | assert props["const"]["maxLength"] == 50 35 | 36 | 37 | def test_length_validator_error(): 38 | class BadSchema(Schema): 39 | bob = fields.Integer(validate=validate.Length(min=1, max=3)) 40 | 41 | class Meta: 42 | strict = True 43 | 44 | schema = BadSchema() 45 | json_schema = JSONSchema() 46 | 47 | with pytest.raises(UnsupportedValueError): 48 | json_schema.dump(schema) 49 | 50 | 51 | def test_one_of_validator(): 52 | schema = UserSchema() 53 | 54 | dumped = validate_and_dump(schema) 55 | 56 | assert dumped["definitions"]["UserSchema"]["properties"]["sex"]["enum"] == [ 57 | "male", 58 | "female", 59 | "non_binary", 60 | "other", 61 | ] 62 | assert dumped["definitions"]["UserSchema"]["properties"]["sex"]["enumNames"] == [ 63 | "Male", 64 | "Female", 65 | "Non-binary/fluid", 66 | "Other", 67 | ] 68 | 69 | 70 | def test_one_of_empty_enum(): 71 | class TestSchema(Schema): 72 | foo = fields.String(validate=OneOf([])) 73 | 74 | schema = TestSchema() 75 | 76 | dumped = validate_and_dump(schema) 77 | 78 | foo_property = dumped["definitions"]["TestSchema"]["properties"]["foo"] 79 | assert foo_property["enum"] == [] 80 | assert foo_property["enumNames"] == [] 81 | 82 | 83 | def test_range(): 84 | class TestSchema(Schema): 85 | foo = fields.Integer( 86 | validate=Range(min=1, min_inclusive=False, max=3, max_inclusive=False) 87 | ) 88 | bar = fields.Integer(validate=Range(min=2, max=4)) 89 | 90 | schema = TestSchema() 91 | 92 | dumped = validate_and_dump(schema) 93 | 94 | props = dumped["definitions"]["TestSchema"]["properties"] 95 | assert props["foo"]["exclusiveMinimum"] == 1 96 | assert props["foo"]["exclusiveMaximum"] == 3 97 | assert props["bar"]["minimum"] == 2 98 | assert props["bar"]["maximum"] == 4 99 | 100 | 101 | def test_range_no_min_or_max(): 102 | class SchemaNoMin(Schema): 103 | foo = fields.Integer(validate=validate.Range(max=4)) 104 | 105 | class SchemaNoMax(Schema): 106 | foo = fields.Integer(validate=validate.Range(min=0)) 107 | 108 | schema1 = SchemaNoMin() 109 | schema2 = SchemaNoMax() 110 | 111 | dumped1 = validate_and_dump(schema1) 112 | dumped2 = validate_and_dump(schema2) 113 | assert dumped1["definitions"]["SchemaNoMin"]["properties"]["foo"]["maximum"] == 4 114 | assert dumped2["definitions"]["SchemaNoMax"]["properties"]["foo"]["minimum"] == 0 115 | 116 | 117 | def test_range_non_number_error(): 118 | class TestSchema(Schema): 119 | foo = fields.String(validate=validate.Range(max=4)) 120 | 121 | schema = TestSchema() 122 | 123 | json_schema = JSONSchema() 124 | 125 | with pytest.raises(UnsupportedValueError): 126 | json_schema.dump(schema) 127 | 128 | 129 | def test_regexp(): 130 | ipv4_regex = ( 131 | r"^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}" 132 | r"([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$" 133 | ) 134 | 135 | class TestSchema(Schema): 136 | ip_address = fields.String(validate=validate.Regexp(ipv4_regex)) 137 | 138 | schema = TestSchema() 139 | 140 | dumped = validate_and_dump(schema) 141 | 142 | assert dumped["definitions"]["TestSchema"]["properties"]["ip_address"] == { 143 | "title": "ip_address", 144 | "type": "string", 145 | "pattern": ipv4_regex, 146 | } 147 | 148 | 149 | def test_regexp_error(): 150 | class TestSchema(Schema): 151 | test_regexp = fields.Int(validate=validate.Regexp(r"\d+")) 152 | 153 | schema = TestSchema() 154 | 155 | with pytest.raises(UnsupportedValueError): 156 | dumped = validate_and_dump(schema) 157 | 158 | 159 | def test_custom_validator(): 160 | class TestValidator(validate.Range): 161 | _jsonschema_base_validator_class = validate.Range 162 | 163 | class TestSchema(Schema): 164 | test_field = fields.Int(validate=TestValidator(min=1, max=10)) 165 | 166 | schema = TestSchema() 167 | 168 | dumped = validate_and_dump(schema) 169 | 170 | props = dumped["definitions"]["TestSchema"]["properties"] 171 | assert props["test_field"]["minimum"] == 1 172 | assert props["test_field"]["maximum"] == 10 173 | 174 | 175 | def test_enum(): 176 | class TestEnum(Enum): 177 | value_1 = 0 178 | value_2 = 1 179 | 180 | class TestSchema(Schema): 181 | foo = EnumField(TestEnum) 182 | 183 | schema = TestSchema() 184 | 185 | dumped = validate_and_dump(schema) 186 | 187 | foo_property = dumped["definitions"]["TestSchema"]["properties"]["foo"] 188 | assert foo_property["enum"] == ["value_1", "value_2"] 189 | 190 | 191 | def test_union(): 192 | class TestSchema(Schema): 193 | foo = Union([fields.String(), fields.Integer()]) 194 | 195 | schema = TestSchema() 196 | 197 | dumped = validate_and_dump(schema) 198 | 199 | foo_property = dumped["definitions"]["TestSchema"]["properties"]["foo"] 200 | assert {"title": "", "type": "string"} in foo_property["anyOf"] 201 | assert {"title": "", "type": "integer"} in foo_property["anyOf"] 202 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py{37,38,39,310,311,py3} 3 | 4 | [testenv] 5 | deps=-r requirements-test.txt 6 | allowlist_externals=make 7 | commands=make test_coverage 8 | --------------------------------------------------------------------------------