├── tests ├── __init__.py ├── diff │ ├── __init__.py │ ├── test_union.py │ ├── test_object_type.py │ ├── test_enum.py │ ├── test_interface.py │ ├── test_argument.py │ ├── test_schema.py │ ├── test_input_object.py │ ├── test_field.py │ └── test_directive.py ├── data │ ├── invalid_schema.gql │ ├── simple_schema.gql │ ├── simple_schema_breaking_changes.gql │ ├── simple_schema_dangerous_and_breaking.gql │ ├── simple_schema_dangerous_changes.gql │ ├── simple_schema_rules_validation.gql │ ├── simple_schema_rules_validation_new.gql │ ├── allowlist.json │ ├── old_schema.gql │ └── new_schema.gql ├── test_allow_list.py ├── test_formatting.py ├── test_schema_loading.py ├── test_change_severity.py ├── test_schema_integration.py ├── test_changes_as_json.py ├── test_cli.py └── test_validation_rules.py ├── schemadiff ├── diff │ ├── __init__.py │ ├── union_type.py │ ├── interface.py │ ├── argument.py │ ├── enum.py │ ├── input_object_type.py │ ├── field.py │ ├── object_type.py │ ├── directive.py │ └── schema.py ├── schema_loader.py ├── changes │ ├── schema.py │ ├── union.py │ ├── object.py │ ├── type.py │ ├── argument.py │ ├── enum.py │ ├── field.py │ ├── input.py │ ├── interface.py │ ├── __init__.py │ └── directive.py ├── formatting.py ├── validation.py ├── __init__.py ├── allow_list.py ├── __main__.py └── validation_rules │ └── __init__.py ├── _config.yml ├── .flake8 ├── requirements.txt ├── images ├── logo.png ├── usage.gif └── logo.svg ├── requirements_dev.txt ├── Dockerfile ├── .travis.yml ├── Makefile ├── .github └── workflows │ └── pythonpublish.yml ├── pyproject.toml ├── .gitignore ├── README.md └── docs ├── schema_loader.html └── allow_list.html /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/diff/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /schemadiff/diff/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | . # Instruct setuptools to run 'python setup.py install' -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ambro17/graphql-schema-diff/HEAD/images/logo.png -------------------------------------------------------------------------------- /images/usage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ambro17/graphql-schema-diff/HEAD/images/usage.gif -------------------------------------------------------------------------------- /tests/data/invalid_schema.gql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | } 4 | 5 | type Query { 6 | a: InvalidType! 7 | } 8 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | flake8 3 | pytest 4 | pytest-cov 5 | codecov 6 | pdoc3==0.9.1 7 | -e . # Install schemadiff in editable form for local development 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-slim 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | 5 | RUN pip install graphql-schema-diff==1.0.2 6 | 7 | WORKDIR /app 8 | 9 | ENTRYPOINT ["schemadiff"] 10 | -------------------------------------------------------------------------------- /tests/data/simple_schema.gql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | } 4 | 5 | type Query { 6 | a: Int! 7 | b: Field 8 | } 9 | 10 | type Field { 11 | calculus(x: Int=0): Int! 12 | } 13 | -------------------------------------------------------------------------------- /tests/data/simple_schema_breaking_changes.gql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | } 4 | 5 | type Query { 6 | b: Field 7 | } 8 | 9 | type Field { 10 | calculus(x: Int=0): Int! 11 | } 12 | -------------------------------------------------------------------------------- /tests/data/simple_schema_dangerous_and_breaking.gql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | } 4 | 5 | type Query { 6 | b: Field 7 | c: Int 8 | } 9 | 10 | type Field { 11 | calculus(x: Int=1): Int! 12 | } 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | - "3.7" 5 | - "3.8" 6 | install: 7 | - pip install -r requirements_dev.txt 8 | script: 9 | - make test-coverage 10 | after_success: 11 | - codecov 12 | -------------------------------------------------------------------------------- /tests/data/simple_schema_dangerous_changes.gql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | } 4 | 5 | type Query { 6 | a: Int! 7 | b: Field 8 | c: Float 9 | } 10 | 11 | type Field { 12 | calculus(x: Int=100): Int! 13 | } -------------------------------------------------------------------------------- /tests/data/simple_schema_rules_validation.gql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | } 4 | 5 | type Query { 6 | a: Int! 7 | b: Field 8 | } 9 | 10 | """Some desc""" 11 | type Field { 12 | """Field desc""" 13 | calculus(x: Int=0): Int! 14 | } 15 | 16 | enum Enum { 17 | VALUE_1 18 | """Value 2 desc""" 19 | VALUE_2 20 | } 21 | -------------------------------------------------------------------------------- /tests/data/simple_schema_rules_validation_new.gql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | } 4 | 5 | type Query { 6 | a: Int! 7 | b: Field 8 | c: NewTypeWithoutDesc 9 | } 10 | 11 | type Field { 12 | calculus(x: Int=0): Int! 13 | } 14 | 15 | type NewTypeWithoutDesc { 16 | data: Int 17 | } 18 | 19 | enum NewEnumWithoutDesc { 20 | VALUE 21 | } 22 | 23 | enum Enum { 24 | VALUE_1 25 | VALUE_2 26 | VALUE_3 27 | } 28 | -------------------------------------------------------------------------------- /tests/data/allowlist.json: -------------------------------------------------------------------------------- 1 | { 2 | "5fba3d6ffc43c6769c6959ce5cb9b1c8": { 3 | "acceptance_reason": "It is correctly handled on FE already", 4 | "criticality": { 5 | "level": "DANGEROUS", 6 | "reason": "Changing the default value for an argument may change the runtime behaviour of a field if it was never provided." 7 | }, 8 | "message": "Default value for argument `x` on field `Field.calculus` changed from `0` to `100`", 9 | "path": "Field.calculus" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /schemadiff/schema_loader.py: -------------------------------------------------------------------------------- 1 | from graphql import build_schema, GraphQLSchema 2 | 3 | 4 | class SchemaLoader: 5 | """Represents a GraphQL Schema loaded from a string or file.""" 6 | 7 | @classmethod 8 | def from_sdl(cls, schema_string: str) -> GraphQLSchema: 9 | return build_schema(schema_string) 10 | 11 | @classmethod 12 | def from_file(cls, filepath: str) -> GraphQLSchema: 13 | with open(filepath, encoding='utf-8') as f: 14 | schema_string = f.read() 15 | 16 | return cls.from_sdl(schema_string) 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test test-coverage test-coverage-html flake8 clean docs 2 | 3 | WORK_DIR=./schemadiff 4 | 5 | test: 6 | pytest 7 | 8 | test-coverage: 9 | pytest --cov $(WORK_DIR) 10 | 11 | test-coverage-html: 12 | pytest --cov $(WORK_DIR) --cov-report html 13 | 14 | flake8: 15 | flake8 --count --exit-zero $(WORK_DIR) --output-file flake8_issues.txt 16 | 17 | clean: 18 | find . -name "*.py[co]" -o -name __pycache__ -exec rm -rf {} + 19 | 20 | docs: 21 | pdoc3 schemadiff/ --html -o docs --force && \ 22 | # Remove nested dir as github pages expects an index.html on docs folder \ 23 | cp -r docs/schemadiff/* docs && rm -rf docs/schemadiff && echo "📚 Docs updated successfully ✨" 24 | 25 | publish: 26 | hatch build 27 | hatch publish 28 | rm -rf dist/ build/ 29 | 30 | 31 | -------------------------------------------------------------------------------- /schemadiff/diff/union_type.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes.union import UnionMemberAdded, UnionMemberRemoved 2 | 3 | 4 | class UnionType: 5 | 6 | def __init__(self, old_type, new_type): 7 | self.type = new_type 8 | self.old_values = old_type.types 9 | self.new_values = new_type.types 10 | 11 | def diff(self): 12 | changes = [] 13 | 14 | old_values = set(x.name for x in self.old_values) 15 | new_values = set(x.name for x in self.new_values) 16 | 17 | added = new_values - old_values 18 | removed = old_values - new_values 19 | 20 | changes.extend(UnionMemberAdded(self.type, value) for value in added) 21 | changes.extend(UnionMemberRemoved(self.type, value) for value in removed) 22 | 23 | return changes -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | python -m pip install hatch 25 | - name: Build and publish 26 | env: 27 | HATCH_INDEX_USER: "__token__" 28 | HATCH_INDEX_AUTH: ${{ secrets.PYPI_API_TOKEN }} 29 | run: | 30 | hatch build 31 | hatch publish -------------------------------------------------------------------------------- /schemadiff/changes/schema.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes import Change, Criticality 2 | 3 | 4 | class SchemaChange(Change): 5 | criticality = Criticality.breaking('Changing a root type is a breaking change') 6 | 7 | def __init__(self, old_type, new_type): 8 | self.old_type = old_type 9 | self.new_type = new_type 10 | 11 | @property 12 | def path(self): 13 | return self.new_type 14 | 15 | 16 | class SchemaQueryTypeChanged(SchemaChange): 17 | 18 | @property 19 | def message(self): 20 | return f"Schema query root has changed from `{self.old_type}` to `{self.new_type}`" 21 | 22 | 23 | class SchemaMutationTypeChanged(SchemaChange): 24 | 25 | @property 26 | def message(self): 27 | return f"Schema mutation root has changed from `{self.old_type}` to `{self.new_type}`" 28 | 29 | 30 | class SchemaSubscriptionTypeChanged(SchemaChange): 31 | 32 | @property 33 | def message(self): 34 | return f"Schema subscription root has changed from `{self.old_type}` to `{self.new_type}`" 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "graphql-schema-diff" 7 | description = "Compare GraphQL Schemas" 8 | readme = "README.md" 9 | version = "1.2.5" 10 | authors = [ 11 | { name = "Nahuel Ambrosini", email = "ambro17.1@gmail.com" } 12 | ] 13 | dependencies = [ 14 | "graphql-core>=3.0.1", 15 | "attrs>=19.3.0", 16 | ] 17 | requires-python = ">=3.6" 18 | classifiers = [ 19 | "Programming Language :: Python :: 3", 20 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 21 | "Operating System :: OS Independent", 22 | ] 23 | license = "GPL-3.0-or-later" 24 | 25 | [project.scripts] 26 | schemadiff = "schemadiff.__main__:cli" 27 | 28 | [project.urls] 29 | Homepage = "https://github.com/Ambro17/graphql-schema-diff" 30 | Documentation = "https://ambro17.github.io/graphql-schema-diff/" 31 | 32 | [project.optional-dependencies] 33 | dev = [ 34 | "pytest", 35 | "flake8", 36 | "pytest", 37 | "pytest-cov", 38 | "codecov", 39 | "pdoc3==0.9.1", 40 | "hatch", 41 | ] 42 | 43 | [tool.hatch.build.targets.wheel] 44 | packages = ["schemadiff"] 45 | -------------------------------------------------------------------------------- /schemadiff/diff/interface.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes.interface import InterfaceFieldAdded, InterfaceFieldRemoved 2 | from schemadiff.diff.field import Field 3 | 4 | 5 | class InterfaceType: 6 | 7 | def __init__(self, old_interface, new_interface): 8 | self.old_face = old_interface 9 | self.new_face = new_interface 10 | 11 | self.old_fields = set(old_interface.fields) 12 | self.new_fields = set(new_interface.fields) 13 | 14 | def diff(self): 15 | changes = [] 16 | 17 | added = self.new_fields - self.old_fields 18 | removed = self.old_fields - self.new_fields 19 | changes.extend(InterfaceFieldAdded(self.new_face, name, self.new_face.fields[name]) for name in added) 20 | changes.extend(InterfaceFieldRemoved(self.new_face, field_name) for field_name in removed) 21 | 22 | common = self.old_fields & self.new_fields 23 | for field_name in common: 24 | old_field = self.old_face.fields[field_name] 25 | new_field = self.new_face.fields[field_name] 26 | 27 | changes += Field(self.new_face, field_name, old_field, new_field).diff() or [] 28 | 29 | return changes 30 | -------------------------------------------------------------------------------- /tests/test_allow_list.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | from schemadiff.allow_list import read_allowed_changes, InvalidAllowlist 6 | 7 | 8 | def test_read_from_invalid_json(): 9 | with pytest.raises(InvalidAllowlist, match='Invalid json format provided.'): 10 | read_allowed_changes("") 11 | 12 | 13 | def test_allow_list_is_not_a_mapping(): 14 | with pytest.raises(InvalidAllowlist, match='Allowlist must be a mapping.'): 15 | read_allowed_changes("[]") 16 | 17 | 18 | def test_allow_list_keys_are_not_change_checksums(): 19 | with pytest.raises(InvalidAllowlist, match='All keys must be a valid md5 checksum'): 20 | read_allowed_changes(json.dumps( 21 | { 22 | '1e3b776bda2dd8b11804e7341bb8b2d1': 'md5 checksum key', 23 | 'bad key': 'some message' 24 | } 25 | )) 26 | 27 | 28 | def test_read_from_valid_json(): 29 | original = """{ 30 | "1e3b776bda2dd8b11804e7341bb8b2d1": "lorem", 31 | "1234776bda2dd8b11804e7341bb8b2d1": "ipsum" 32 | }""" 33 | assert read_allowed_changes(original) == json.loads(original) == { 34 | "1e3b776bda2dd8b11804e7341bb8b2d1": "lorem", 35 | "1234776bda2dd8b11804e7341bb8b2d1": "ipsum", 36 | } -------------------------------------------------------------------------------- /schemadiff/changes/union.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes import Change, Criticality 2 | 3 | 4 | class UnionMemberAdded(Change): 5 | 6 | criticality = Criticality.dangerous( 7 | "Adding a possible type to Unions may break existing clients " 8 | "that were not programming defensively against a new possible type." 9 | ) 10 | 11 | def __init__(self, union, value): 12 | self.union = union 13 | self.value = value 14 | 15 | @property 16 | def message(self): 17 | return f"Union member `{self.value}` was added to `{self.union.name}` Union type" 18 | 19 | @property 20 | def path(self): 21 | return f"{self.union.name}" 22 | 23 | 24 | class UnionMemberRemoved(Change): 25 | 26 | criticality = Criticality.breaking( 27 | 'Removing a union member from a union can break ' 28 | 'queries that use this union member in a fragment spread' 29 | ) 30 | 31 | def __init__(self, union, value): 32 | self.union = union 33 | self.value = value 34 | 35 | @property 36 | def message(self): 37 | return f"Union member `{self.value}` was removed from `{self.union.name}` Union type" 38 | 39 | @property 40 | def path(self): 41 | return f"{self.union.name}" 42 | -------------------------------------------------------------------------------- /schemadiff/diff/argument.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes.argument import ( 2 | FieldArgumentDescriptionChanged, 3 | FieldArgumentTypeChanged, 4 | FieldArgumentDefaultValueChanged, 5 | ) 6 | 7 | 8 | class Argument: 9 | 10 | def __init__(self, type_, field_name, argument_name, old_arg, new_arg): 11 | self.type_ = type_ 12 | self.field_name = field_name 13 | self.argument_name = argument_name 14 | self.old_arg = old_arg 15 | self.new_arg = new_arg 16 | 17 | def diff(self): 18 | changes = [] 19 | if self.old_arg.description != self.new_arg.description: 20 | changes.append(FieldArgumentDescriptionChanged( 21 | self.type_, self.field_name, self.argument_name, self.old_arg, self.new_arg 22 | )) 23 | if self.old_arg.default_value != self.new_arg.default_value: 24 | changes.append(FieldArgumentDefaultValueChanged( 25 | self.type_, self.field_name, self.argument_name, self.old_arg, self.new_arg 26 | )) 27 | if str(self.old_arg.type) != str(self.new_arg.type): 28 | changes.append(FieldArgumentTypeChanged( 29 | self.type_, self.field_name, self.argument_name, self.old_arg, self.new_arg 30 | )) 31 | 32 | return changes 33 | -------------------------------------------------------------------------------- /tests/data/old_schema.gql: -------------------------------------------------------------------------------- 1 | directive @willBeRemoved on FIELD 2 | 3 | schema { 4 | query: Query 5 | } 6 | input AInput { 7 | """a""" 8 | a: String = "1" 9 | b: String! 10 | } 11 | """The Query Root of this schema""" 12 | type Query { 13 | """Just a simple string""" 14 | a(anArg: String): String! 15 | b: BType 16 | } 17 | type BType { 18 | a: String 19 | } 20 | type CType { 21 | a: String @deprecated(reason: "whynot") 22 | c: Int! 23 | d(arg: Int): String 24 | } 25 | union MyUnion = CType | BType 26 | interface AnInterface { 27 | interfaceField: Int! 28 | } 29 | interface AnotherInterface { 30 | anotherInterfaceField: String 31 | } 32 | type WithInterfaces implements AnInterface & AnotherInterface { 33 | a: String! 34 | } 35 | type WithArguments { 36 | a( 37 | """Meh""" 38 | a: Int 39 | b: String 40 | ): String 41 | b(arg: Int = 1): String 42 | } 43 | enum Options { 44 | A 45 | B 46 | C 47 | E 48 | F @deprecated(reason: "Old") 49 | } 50 | 51 | """Old""" 52 | directive @yolo( 53 | """Included when true.""" 54 | someArg: Boolean! 55 | 56 | anotherArg: String! 57 | 58 | willBeRemoved: Boolean! 59 | ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT 60 | 61 | type WillBeRemoved { 62 | a: String 63 | } -------------------------------------------------------------------------------- /tests/data/new_schema.gql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | } 4 | input AInput { 5 | # changed 6 | a: Int = 1 7 | c: String! 8 | } 9 | # Query Root description changed 10 | type Query { 11 | # This description has been changed 12 | a: String! 13 | b: Int! 14 | } 15 | input BType { 16 | a: String! 17 | } 18 | type CType implements AnInterface { 19 | a(arg: Int): String @deprecated(reason: "cuz") 20 | b: Int! 21 | d(arg: Int = 10): String 22 | } 23 | type DType { 24 | b: Int! 25 | } 26 | union MyUnion = CType | DType 27 | interface AnInterface { 28 | interfaceField: Int! 29 | } 30 | interface AnotherInterface { 31 | b: Int 32 | } 33 | type WithInterfaces implements AnInterface { 34 | a: String! 35 | } 36 | type WithArguments { 37 | a( 38 | # Description for a 39 | a: Int 40 | b: String! 41 | ): String 42 | b(arg: Int = 2): String 43 | } 44 | enum Options { 45 | # Stuff 46 | A 47 | B 48 | D 49 | E @deprecated 50 | F @deprecated(reason: "New") 51 | } 52 | 53 | # New 54 | directive @yolo( 55 | # someArg does stuff 56 | someArg: String! 57 | 58 | anotherArg: String! = "Test" 59 | ) on FIELD | FIELD_DEFINITION 60 | 61 | directive @yolo2( 62 | # Included when true. 63 | someArg: String! 64 | ) on FIELD -------------------------------------------------------------------------------- /schemadiff/diff/enum.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes.enum import ( 2 | EnumValueAdded, 3 | EnumValueRemoved, 4 | EnumValueDescriptionChanged, 5 | EnumValueDeprecationReasonChanged, 6 | ) 7 | 8 | 9 | class EnumDiff: 10 | 11 | def __init__(self, old_enum, new_enum): 12 | self.enum = new_enum 13 | self.old_values = old_enum.values 14 | self.new_values = new_enum.values 15 | 16 | def diff(self): 17 | changes = [] 18 | old_values = set(self.old_values) 19 | new_values = set(self.new_values) 20 | 21 | added = new_values - old_values 22 | removed = old_values - new_values 23 | changes.extend(EnumValueAdded(self.enum, value) for value in added) 24 | changes.extend(EnumValueRemoved(self.enum, value) for value in removed) 25 | 26 | common = old_values & new_values 27 | for enum_name in common: 28 | old_value = self.old_values[enum_name] 29 | new_value = self.new_values[enum_name] 30 | if old_value.description != new_value.description: 31 | changes.append(EnumValueDescriptionChanged(self.enum, enum_name, old_value, new_value)) 32 | if old_value.deprecation_reason != new_value.deprecation_reason: 33 | changes.append(EnumValueDeprecationReasonChanged(self.enum, enum_name, old_value, new_value)) 34 | 35 | return changes 36 | -------------------------------------------------------------------------------- /schemadiff/formatting.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from typing import List 4 | 5 | from schemadiff.changes import CriticalityLevel, Change 6 | 7 | 8 | def format_diff(changes: List[Change]) -> str: 9 | """Format a list of changes into a printable string""" 10 | changes = '\n'.join( 11 | format_change_by_criticality(change) 12 | for change in changes 13 | ) 14 | return changes or '🎉 Both schemas are equal!' 15 | 16 | 17 | def format_change_by_criticality(change: Change) -> str: 18 | icon_by_criticality = { 19 | CriticalityLevel.Breaking: os.getenv('SD_BREAKING_CHANGE_ICON', '❌'), 20 | CriticalityLevel.Dangerous: os.getenv('SD_DANGEROUS_CHANGE_ICON', '⚠️'), 21 | CriticalityLevel.NonBreaking: os.getenv('SD_SAFE_CHANGE_ICON', '✔️'), 22 | } 23 | icon = icon_by_criticality[change.criticality.level] 24 | if change.restricted is not None: 25 | return f"⛔ {change.restricted}" 26 | return f"{icon} {change.message}" 27 | 28 | 29 | def print_diff(changes: List[Change]) -> None: 30 | """Pretty print a list of changes""" 31 | print(format_diff(changes)) 32 | 33 | 34 | def changes_to_dict(changes: List[Change]) -> List[dict]: 35 | return [ 36 | change.to_dict() 37 | for change in changes 38 | ] 39 | 40 | 41 | def json_dump_changes(changes: List[Change]) -> str: 42 | return json.dumps(changes_to_dict(changes), indent=4) 43 | 44 | 45 | def print_json(changes: List[Change]) -> None: 46 | print(json_dump_changes(changes)) 47 | -------------------------------------------------------------------------------- /schemadiff/diff/input_object_type.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes.input import ( 2 | InputFieldAdded, 3 | InputFieldRemoved, 4 | InputFieldDescriptionChanged, 5 | InputFieldDefaultChanged, 6 | InputFieldTypeChanged, 7 | ) 8 | 9 | 10 | class InputObjectType: 11 | def __init__(self, old_type, new_type): 12 | self.type = new_type 13 | self.old_fields = old_type.fields 14 | self.new_fields = new_type.fields 15 | 16 | def diff(self): 17 | changes = [] 18 | 19 | old_field_names = set(self.old_fields) 20 | new_field_names = set(self.new_fields) 21 | 22 | added = new_field_names - old_field_names 23 | removed = old_field_names - new_field_names 24 | 25 | changes.extend(InputFieldAdded(self.type, field_name, self.new_fields[field_name]) for field_name in added) 26 | changes.extend(InputFieldRemoved(self.type, field_name) for field_name in removed) 27 | 28 | common_types = old_field_names & new_field_names 29 | for type_name in common_types: 30 | old = self.old_fields[type_name] 31 | new = self.new_fields[type_name] 32 | if str(old.type) != str(new.type): 33 | changes.append(InputFieldTypeChanged(self.type, type_name, new, old)) 34 | if old.description != new.description: 35 | changes.append(InputFieldDescriptionChanged(self.type, type_name, new, old)) 36 | if old.default_value != new.default_value: 37 | changes.append(InputFieldDefaultChanged(self.type, type_name, new, old)) 38 | 39 | return changes 40 | -------------------------------------------------------------------------------- /schemadiff/validation.py: -------------------------------------------------------------------------------- 1 | from schemadiff import Change 2 | from typing import List, Dict, Any 3 | 4 | from dataclasses import dataclass 5 | 6 | from schemadiff.validation_rules import ValidationRule 7 | 8 | 9 | @dataclass 10 | class ValidationResult: 11 | ok: bool 12 | errors: List['ValidationError'] 13 | 14 | 15 | @dataclass 16 | class ValidationError: 17 | rule: str 18 | reason: str 19 | change: Change 20 | 21 | 22 | def validate_changes(diff: List[Change], rules: List[str], allowed_changes: Dict[str, Any] = None) -> ValidationResult: 23 | """Given a list of changes between schemas and a list of rules, 24 | it runs all rules against the changes, to detect invalid changes. 25 | It also admits an allowlist of accepted invalid changes to document exceptions to the rules 26 | 27 | Returns: 28 | bool: True if there is at least one restricted change, 29 | False otherwise. 30 | """ 31 | allowed_changes = allowed_changes or {} 32 | is_valid = True 33 | errors = [] 34 | rules = ValidationRule.get_subclasses_by_names(rules) 35 | for change in diff: 36 | for rule in rules: 37 | if not rule(change).is_valid(): 38 | if change.checksum() in allowed_changes: 39 | continue 40 | 41 | change.restricted = rule(change).message 42 | is_valid = False 43 | errors.append(ValidationError(rule.name, change.restricted, change)) 44 | 45 | return ValidationResult(is_valid, errors) 46 | 47 | 48 | def rules_list(): 49 | return ValidationRule.get_rules_list() 50 | -------------------------------------------------------------------------------- /schemadiff/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Union, List 2 | 3 | from graphql import GraphQLSchema as GQLSchema, is_schema 4 | 5 | from schemadiff.changes import Change 6 | from schemadiff.diff.schema import Schema 7 | from schemadiff.schema_loader import SchemaLoader 8 | from schemadiff.formatting import print_diff, format_diff 9 | from schemadiff.validation import validate_changes 10 | 11 | 12 | SDL = str # Alias for string describing schema through schema definition language 13 | 14 | 15 | def diff(old_schema: Union[SDL, GQLSchema], new_schema: Union[SDL, GQLSchema]) -> List[Change]: 16 | """Compare two graphql schemas highlighting dangerous and breaking changes. 17 | 18 | Returns: 19 | changes (List[Change]): List of differences between both schemas with details about each change 20 | """ 21 | first = SchemaLoader.from_sdl(old_schema) if not is_schema(old_schema) else old_schema 22 | second = SchemaLoader.from_sdl(new_schema) if not is_schema(new_schema) else new_schema 23 | return Schema(first, second).diff() 24 | 25 | 26 | def diff_from_file(schema_file: str, other_schema_file: str): 27 | """Compare two graphql schema files highlighting dangerous and breaking changes. 28 | 29 | Returns: 30 | changes (List[Change]): List of differences between both schemas with details about each change 31 | """ 32 | first = SchemaLoader.from_file(schema_file) 33 | second = SchemaLoader.from_file(other_schema_file) 34 | return Schema(first, second).diff() 35 | 36 | 37 | __all__ = [ 38 | 'diff', 39 | 'diff_from_file', 40 | 'format_diff', 41 | 'print_diff', 42 | 'validate_changes', 43 | 'Change', 44 | ] 45 | -------------------------------------------------------------------------------- /schemadiff/changes/object.py: -------------------------------------------------------------------------------- 1 | from graphql import GraphQLField, GraphQLObjectType 2 | 3 | from schemadiff.changes import Change, Criticality 4 | 5 | 6 | class ObjectTypeFieldAdded(Change): 7 | 8 | criticality = Criticality.safe() 9 | 10 | def __init__(self, parent: GraphQLObjectType, field_name, field: GraphQLField): 11 | self.parent = parent 12 | self.field_name = field_name 13 | self.field = field 14 | self.description = parent.fields[field_name].description 15 | 16 | @property 17 | def message(self): 18 | return f"Field `{self.field_name}` was added to object type `{self.parent.name}`" 19 | 20 | @property 21 | def path(self): 22 | return f"{self.parent.name}.{self.field_name}" 23 | 24 | 25 | class ObjectTypeFieldRemoved(Change): 26 | 27 | def __init__(self, parent, field_name, field): 28 | self.parent = parent 29 | self.field_name = field_name 30 | self.field = field 31 | self.criticality = ( 32 | Criticality.dangerous( 33 | "Removing deprecated fields without sufficient time for clients " 34 | "to update their queries may break their code" 35 | ) if field.deprecation_reason else Criticality.breaking( 36 | "Removing a field is a breaking change. It is preferred to deprecate the field before removing it." 37 | ) 38 | ) 39 | 40 | @property 41 | def message(self): 42 | return f"Field `{self.field_name}` was removed from object type `{self.parent.name}`" 43 | 44 | @property 45 | def path(self): 46 | return f"{self.parent.name}.{self.field_name}" 47 | -------------------------------------------------------------------------------- /tests/test_formatting.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from schemadiff.diff.schema import Schema 4 | from schemadiff.schema_loader import SchemaLoader 5 | from schemadiff.formatting import print_diff, print_json 6 | from tests.test_schema_loading import TESTS_DATA 7 | 8 | 9 | def test_print_diff_shows_difference(capsys): 10 | old_schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema.gql') 11 | new_schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema_breaking_changes.gql') 12 | 13 | diff = Schema(old_schema, new_schema).diff() 14 | assert len(diff) == 1 15 | ret = print_diff(diff) 16 | assert ret is None 17 | assert capsys.readouterr().out == ( 18 | '❌ Field `a` was removed from object type `Query`\n' 19 | ) 20 | 21 | 22 | def test_print_json_shows_difference(capsys): 23 | old_schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema.gql') 24 | new_schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema_breaking_changes.gql') 25 | 26 | diff = Schema(old_schema, new_schema).diff() 27 | assert len(diff) == 1 28 | ret = print_json(diff) 29 | assert ret is None 30 | json_output = capsys.readouterr().out 31 | output_as_dict = json.loads(json_output) 32 | change_message = "Field `a` was removed from object type `Query`" 33 | assert output_as_dict == [{ 34 | "message": change_message, 35 | "path": "Query.a", 36 | "is_safe_change": False, 37 | "criticality": { 38 | "level": "BREAKING", 39 | "reason": "Removing a field is a breaking change. " 40 | "It is preferred to deprecate the field before removing it." 41 | }, 42 | "checksum": '5fba3d6ffc43c6769c6959ce5cb9b1c8' 43 | }] 44 | -------------------------------------------------------------------------------- /schemadiff/allow_list.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from json import JSONDecodeError 4 | 5 | 6 | class InvalidAllowlist(Exception): 7 | """Exception raised when the user provides an invalid json file of allowed changes""" 8 | 9 | 10 | def read_allowed_changes(file_content): 11 | """Read a json file that defines a mapping of changes checksums to the reasons of why it is allowed. 12 | 13 | Compliant formats: 14 | { 15 | '0cc175b9c0f1b6a831c399e269772661': 'Removed field `MyField` because clients don't use it', 16 | '92eb5ffee6ae2fec3ad71c777531578f': 'Removed argument `Arg` because it became redundant', 17 | } 18 | But the value can be as detailed as the user wants (as long as the key remains the change checksum) 19 | { 20 | '0cc175b9c0f1b6a831c399e269772661': { 21 | 'date': '2030-01-01 15:00:00, 22 | 'reason': 'my reason', 23 | 'message': 'Field `a` was removed from object type `Query`' 24 | }, 25 | '92eb5ffee6ae2fec3ad71c777531578f': { 26 | 'date': '2031-01-01 23:59:00, 27 | 'reason': 'my new reason', 28 | 'message': 'Field `b` was removed from object type `MyType`' 29 | }, 30 | } 31 | """ 32 | try: 33 | allowlist = json.loads(file_content) 34 | except JSONDecodeError as e: 35 | raise InvalidAllowlist("Invalid json format provided.") from e 36 | if not isinstance(allowlist, dict): 37 | raise InvalidAllowlist("Allowlist must be a mapping.") 38 | 39 | CHECKSUM_REGEX = re.compile(r'[a-fA-F0-9]{32}') 40 | if any(not CHECKSUM_REGEX.match(checksum) for checksum in allowlist.keys()): 41 | raise InvalidAllowlist("All keys must be a valid md5 checksum") 42 | 43 | return allowlist 44 | -------------------------------------------------------------------------------- /tests/diff/test_union.py: -------------------------------------------------------------------------------- 1 | from graphql import build_schema as schema 2 | 3 | from schemadiff.changes import Criticality 4 | from schemadiff.diff.schema import Schema 5 | 6 | 7 | def test_add_type_to_union(): 8 | two_types = schema(""" 9 | type Query { 10 | c: Int 11 | } 12 | type Result { 13 | message: String 14 | } 15 | type Error { 16 | message: String 17 | details: String 18 | } 19 | type Unknown { 20 | message: String 21 | details: String 22 | traceback: String 23 | } 24 | 25 | union Outcome = Result | Error 26 | """) 27 | 28 | three_types = schema(""" 29 | type Query { 30 | c: Int 31 | } 32 | type Result { 33 | message: String 34 | } 35 | type Error { 36 | message: String 37 | details: String 38 | } 39 | type Unknown { 40 | message: String 41 | details: String 42 | traceback: String 43 | } 44 | 45 | union Outcome = Result | Error | Unknown 46 | """) 47 | diff = Schema(two_types, three_types).diff() 48 | assert diff and len(diff) == 1 49 | assert diff[0].message == "Union member `Unknown` was added to `Outcome` Union type" 50 | assert diff[0].path == 'Outcome' 51 | assert diff[0].criticality == Criticality.dangerous( 52 | 'Adding a possible type to Unions may break existing clients ' 53 | 'that were not programming defensively against a new possible type.' 54 | ) 55 | 56 | diff = Schema(three_types, two_types).diff() 57 | assert diff and len(diff) == 1 58 | assert diff[0].message == "Union member `Unknown` was removed from `Outcome` Union type" 59 | assert diff[0].path == 'Outcome' 60 | assert diff[0].criticality == Criticality.breaking( 61 | 'Removing a union member from a union can break queries that use this union member in a fragment spread' 62 | ) 63 | -------------------------------------------------------------------------------- /schemadiff/diff/field.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes.field import ( 2 | FieldDescriptionChanged, 3 | FieldDeprecationReasonChanged, 4 | FieldTypeChanged, 5 | FieldArgumentAdded, 6 | FieldArgumentRemoved 7 | ) 8 | from schemadiff.diff.argument import Argument 9 | 10 | 11 | class Field: 12 | 13 | def __init__(self, parent, name, old_field, new_field): 14 | self.parent = parent 15 | self.field_name = name 16 | self.old_field = old_field 17 | self.new_field = new_field 18 | self.old_args = set(old_field.args) 19 | self.new_args = set(new_field.args) 20 | 21 | def diff(self): 22 | changes = [] 23 | 24 | if self.old_field.description != self.new_field.description: 25 | changes.append(FieldDescriptionChanged(self.parent, self.field_name, self.old_field, self.new_field)) 26 | 27 | if self.old_field.deprecation_reason != self.new_field.deprecation_reason: 28 | changes.append(FieldDeprecationReasonChanged(self.parent, self.field_name, self.old_field, self.new_field)) 29 | 30 | if str(self.old_field.type) != str(self.new_field.type): 31 | changes.append(FieldTypeChanged(self.parent, self.field_name, self.old_field, self.new_field)) 32 | 33 | added = self.new_args - self.old_args 34 | removed = self.old_args - self.new_args 35 | 36 | changes.extend( 37 | FieldArgumentAdded(self.parent, self.field_name, self.new_field, arg_name, self.new_field.args[arg_name]) 38 | for arg_name in added 39 | ) 40 | changes.extend( 41 | FieldArgumentRemoved(self.parent, self.field_name, arg_name) 42 | for arg_name in removed 43 | ) 44 | 45 | common_arguments = self.common_arguments() 46 | for arg_name in common_arguments: 47 | old_arg = self.old_field.args[arg_name] 48 | new_arg = self.new_field.args[arg_name] 49 | changes += Argument(self.parent, self.field_name, arg_name, old_arg, new_arg).diff() or [] 50 | 51 | return changes 52 | 53 | def common_arguments(self): 54 | return self.old_args & self.new_args 55 | -------------------------------------------------------------------------------- /tests/test_schema_loading.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from graphql import is_schema, GraphQLSyntaxError 5 | 6 | from schemadiff import diff, diff_from_file 7 | from schemadiff.schema_loader import SchemaLoader 8 | 9 | TESTS_DATA = Path(__file__).parent / 'data' 10 | 11 | 12 | def test_load_from_file(): 13 | schema = SchemaLoader.from_file(TESTS_DATA / 'simple_schema.gql') 14 | assert is_schema(schema) 15 | assert len(schema.query_type.fields) == 2 16 | 17 | 18 | def test_load_from_string(): 19 | schema_string = """ 20 | schema { 21 | query: Query 22 | } 23 | 24 | type Query { 25 | a: ID! 26 | b: MyType 27 | } 28 | 29 | type MyType { 30 | c: String 31 | d: Float 32 | } 33 | """ 34 | schema = SchemaLoader.from_sdl(schema_string) 35 | assert is_schema(schema) 36 | assert len(schema.query_type.fields) == 2 37 | 38 | 39 | def test_diff_from_str(): 40 | schema_str = """ 41 | schema { 42 | query: Query 43 | } 44 | type Query { 45 | a: ID! 46 | b: Int 47 | } 48 | """ 49 | changes = diff(schema_str, schema_str) 50 | assert changes == [] 51 | 52 | 53 | def test_diff_from_schema(): 54 | schema_object = SchemaLoader.from_sdl(""" 55 | schema { 56 | query: Query 57 | } 58 | type Query { 59 | a: ID! 60 | b: Int 61 | } 62 | """) 63 | assert is_schema(schema_object) 64 | changes = diff(schema_object, schema_object) 65 | assert changes == [] 66 | 67 | 68 | def test_diff_from_file(): 69 | changes = diff_from_file(TESTS_DATA / 'simple_schema.gql', TESTS_DATA / 'simple_schema_dangerous_changes.gql') 70 | assert changes 71 | assert len(changes) == 2 72 | 73 | 74 | def test_load_invalid_schema(): 75 | with pytest.raises(TypeError, match="Unknown type 'InvalidType'"): 76 | SchemaLoader.from_file(TESTS_DATA / 'invalid_schema.gql') 77 | 78 | 79 | @pytest.mark.parametrize("schema", ["", "{}", "\n{}\n", "[]"]) 80 | def test_load_empty_schema(schema): 81 | with pytest.raises(GraphQLSyntaxError): 82 | SchemaLoader.from_sdl(schema) 83 | -------------------------------------------------------------------------------- /schemadiff/changes/type.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes import Change, Criticality 2 | 3 | 4 | class RemovedType(Change): 5 | 6 | criticality = Criticality.breaking( 7 | "Removing a type is a breaking change. " 8 | "It is preferred to deprecate and remove all references to this type first." 9 | ) 10 | 11 | def __init__(self, type_): 12 | self.type_ = type_ 13 | 14 | @property 15 | def message(self): 16 | return f"Type `{self.type_.name}` was removed" 17 | 18 | @property 19 | def path(self): 20 | return f'{self.type_.name}' 21 | 22 | 23 | class AddedType(Change): 24 | 25 | criticality = Criticality.safe() 26 | 27 | def __init__(self, added_type): 28 | self.type = added_type 29 | 30 | @property 31 | def message(self): 32 | return f"Type `{self.type.name}` was added" 33 | 34 | @property 35 | def path(self): 36 | return f'{self.type.name}' 37 | 38 | 39 | class TypeDescriptionChanged(Change): 40 | 41 | criticality = Criticality.safe() 42 | 43 | def __init__(self, type_name, old_desc, new_desc): 44 | self.type = type_name 45 | self.old_desc = old_desc 46 | self.new_desc = new_desc 47 | 48 | @property 49 | def message(self): 50 | return ( 51 | f"Description for type `{self.type}` changed from " 52 | f"`{self.old_desc}` to `{self.new_desc}`" 53 | ) 54 | 55 | @property 56 | def path(self): 57 | return self.type 58 | 59 | 60 | class TypeKindChanged(Change): 61 | criticality = Criticality.breaking( 62 | "Changing the kind of a type is a breaking change because " 63 | "it can cause existing queries to error. " 64 | "For example, turning an object type to a scalar type " 65 | "would break queries that define a selection set for this type." 66 | ) 67 | 68 | def __init__(self, type_, old_kind, new_kind): 69 | self.type_ = type_ 70 | self.old_kind = old_kind 71 | self.new_kind = new_kind 72 | 73 | @property 74 | def message(self): 75 | return f"`{self.type_}` kind changed from `{self.old_kind.value.upper()}` to `{self.new_kind.value.upper()}`" 76 | 77 | @property 78 | def path(self): 79 | return f"{self.type_}" 80 | -------------------------------------------------------------------------------- /schemadiff/changes/argument.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes import Change, Criticality, is_safe_change_for_input_value 2 | 3 | 4 | class FieldAbstractArgumentChange(Change): 5 | def __init__(self, parent_type, field, arg_name, old_arg, new_arg): 6 | self.parent = parent_type 7 | self.field_name = field 8 | self.arg_name = arg_name 9 | self.old_arg = old_arg 10 | self.new_arg = new_arg 11 | 12 | @property 13 | def path(self): 14 | return f"{self.parent.name}.{self.field_name}" 15 | 16 | 17 | class FieldArgumentDescriptionChanged(FieldAbstractArgumentChange): 18 | 19 | criticality = Criticality.safe() 20 | 21 | @property 22 | def message(self): 23 | return ( 24 | f"Description for argument `{self.arg_name}` on field `{self.parent}.{self.field_name}` " 25 | f"changed from `{self.old_arg.description}` to `{self.new_arg.description}`" 26 | ) 27 | 28 | 29 | class FieldArgumentDefaultValueChanged(FieldAbstractArgumentChange): 30 | criticality = Criticality.dangerous( 31 | "Changing the default value for an argument may change the runtime " 32 | "behaviour of a field if it was never provided." 33 | ) 34 | 35 | @property 36 | def message(self): 37 | return ( 38 | f"Default value for argument `{self.arg_name}` on field `{self.parent}.{self.field_name}` " 39 | f"changed from `{self.old_arg.default_value!r}` to `{self.new_arg.default_value!r}`" 40 | ) 41 | 42 | 43 | class FieldArgumentTypeChanged(FieldAbstractArgumentChange): 44 | 45 | def __init__(self, parent_type, field, arg_name, old_arg, new_arg): 46 | super().__init__(parent_type, field, arg_name, old_arg, new_arg) 47 | self.criticality = ( 48 | Criticality.safe() 49 | if is_safe_change_for_input_value(old_arg.type, new_arg.type) 50 | else Criticality.breaking( 51 | "Changing the type of a field's argument can break existing queries that use this argument." 52 | ) 53 | ) 54 | 55 | @property 56 | def message(self): 57 | return ( 58 | f"Type for argument `{self.arg_name}` on field `{self.parent}.{self.field_name}` " 59 | f"changed from `{self.old_arg.type}` to `{self.new_arg.type}`" 60 | ) 61 | -------------------------------------------------------------------------------- /tests/diff/test_object_type.py: -------------------------------------------------------------------------------- 1 | from graphql import build_schema as schema 2 | 3 | from schemadiff.changes import Criticality 4 | from schemadiff.diff.schema import Schema 5 | 6 | 7 | def test_object_type_added_field(): 8 | a = schema(""" 9 | type MyType{ 10 | a: Int 11 | } 12 | """) 13 | b = schema(""" 14 | type MyType{ 15 | a: Int 16 | b: String! 17 | } 18 | """) 19 | diff = Schema(a, b).diff() 20 | assert diff and len(diff) == 1 21 | assert diff[0].message == "Field `b` was added to object type `MyType`" 22 | assert diff[0].path == 'MyType.b' 23 | assert diff[0].criticality == Criticality.safe() 24 | 25 | diff = Schema(b, a).diff() 26 | assert diff and len(diff) == 1 27 | assert diff[0].message == "Field `b` was removed from object type `MyType`" 28 | assert diff[0].path == 'MyType.b' 29 | assert diff[0].criticality == Criticality.breaking( 30 | 'Removing a field is a breaking change. It is preferred to deprecate the field before removing it.' 31 | ) 32 | 33 | 34 | def test_object_type_description_changed(): 35 | a = schema(''' 36 | """docstring""" 37 | type MyType{ 38 | a: Int 39 | } 40 | ''') 41 | b = schema(''' 42 | """my new docstring""" 43 | type MyType{ 44 | a: Int 45 | } 46 | ''') 47 | diff = Schema(a, b).diff() 48 | assert diff and len(diff) == 1 49 | assert diff[0].message == "Description for type `MyType` changed from `docstring` to `my new docstring`" 50 | assert diff[0].path == 'MyType' 51 | assert diff[0].criticality == Criticality.safe() 52 | 53 | 54 | def test_type_kind_change(): 55 | atype = schema(''' 56 | type MyType{ 57 | a: Int 58 | } 59 | ''') 60 | input_type = schema(''' 61 | input MyType{ 62 | a: Int 63 | } 64 | ''') 65 | diff = Schema(atype, input_type).diff() 66 | assert diff and len(diff) == 1 67 | assert diff[0].message == "`MyType` kind changed from `OBJECT` to `INPUT OBJECT`" 68 | assert diff[0].path == 'MyType' 69 | assert diff[0].criticality == Criticality.breaking( 70 | 'Changing the kind of a type is a breaking change because it can ' 71 | 'cause existing queries to error. For example, turning an object ' 72 | 'type to a scalar type would break queries that define a selection set for this type.' 73 | ) 74 | -------------------------------------------------------------------------------- /schemadiff/diff/object_type.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes.object import ObjectTypeFieldAdded, ObjectTypeFieldRemoved 2 | from schemadiff.changes.interface import NewInterfaceImplemented, DroppedInterfaceImplementation 3 | from schemadiff.diff.field import Field 4 | 5 | 6 | class ObjectType: 7 | 8 | def __init__(self, old, new): 9 | self.old = old 10 | self.new = new 11 | 12 | self.old_field_names = set(old.fields) 13 | self.new_field_names = set(new.fields) 14 | 15 | self.old_interfaces = set(old.interfaces) 16 | self.new_interfaces = set(new.interfaces) 17 | 18 | def diff(self): 19 | changes = [] 20 | 21 | # Added and removed fields 22 | added = self.new_field_names - self.old_field_names 23 | removed = self.old_field_names - self.new_field_names 24 | changes.extend(ObjectTypeFieldAdded(self.new, field_name, self.new.fields[field_name]) for field_name in added) 25 | changes.extend(ObjectTypeFieldRemoved(self.new, field_name, self.old.fields[field_name]) 26 | for field_name in removed) 27 | 28 | # Added and removed interfaces 29 | added = self.added_interfaces() 30 | removed = self.removed_interfaces() 31 | changes.extend(NewInterfaceImplemented(interface, self.new) for interface in added) 32 | changes.extend(DroppedInterfaceImplementation(interface, self.new) for interface in removed) 33 | 34 | for field_name in self.common_fields(): 35 | old_field = self.old.fields[field_name] 36 | new_field = self.new.fields[field_name] 37 | changes += Field(self.new, field_name, old_field, new_field).diff() or [] 38 | 39 | return changes 40 | 41 | def common_fields(self): 42 | return self.old_field_names & self.new_field_names 43 | 44 | def added_interfaces(self): 45 | """Compare interfaces equality by name. Internal diffs are solved later""" 46 | old_interface_names = {str(x) for x in self.old_interfaces} 47 | return [interface for interface in self.new_interfaces 48 | if str(interface) not in old_interface_names] 49 | 50 | def removed_interfaces(self): 51 | """Compare interfaces equality by name. Internal diffs are solved later""" 52 | new_interface_names = {str(x) for x in self.new_interfaces} 53 | return [interface for interface in self.old_interfaces 54 | if str(interface) not in new_interface_names] -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | backlog 2 | .idea/ 3 | .pytest_cache/ 4 | .vscode/ 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | -------------------------------------------------------------------------------- /tests/test_change_severity.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes import Change, Criticality 2 | 3 | 4 | def test_safe_change(): 5 | class NonBreakingChange(Change): 6 | criticality = Criticality.safe('this is a safe change') 7 | 8 | def message(self): 9 | return 'test message' 10 | 11 | def path(self): 12 | return 'test path' 13 | 14 | c = NonBreakingChange() 15 | assert c.breaking is False 16 | assert c.dangerous is False 17 | assert c.safe is True 18 | assert c.criticality.reason == 'this is a safe change' 19 | 20 | 21 | def test_breaking_change(): 22 | class BreakingChange(Change): 23 | criticality = Criticality.breaking('this is breaking') 24 | 25 | def message(self): 26 | return 'test message' 27 | 28 | def path(self): 29 | return 'test path' 30 | 31 | c = BreakingChange() 32 | assert c.breaking is True 33 | assert c.dangerous is False 34 | assert c.safe is False 35 | assert c.criticality.reason == 'this is breaking' 36 | 37 | 38 | def test_dangerous_change(): 39 | class DangerousChange(Change): 40 | criticality = Criticality.dangerous('this is dangerous') 41 | 42 | def message(self): 43 | return 'test message' 44 | 45 | def path(self): 46 | return 'test path' 47 | 48 | c = DangerousChange() 49 | assert c.breaking is False 50 | assert c.dangerous is True 51 | assert c.safe is False 52 | assert c.criticality.reason == 'this is dangerous' 53 | 54 | 55 | def test_change_str_method_shows_change_message(): 56 | class Testchange(Change): 57 | criticality = Criticality.safe('this is a safe change') 58 | 59 | @property 60 | def message(self): 61 | return 'test message' 62 | 63 | def path(self): 64 | return 'Query.path' 65 | 66 | change = Testchange() 67 | assert str(change) == 'test message' 68 | 69 | 70 | def test_change_repr_simulates_class_instantiation(): 71 | class Testchange(Change): 72 | criticality = Criticality.safe('this is a safe change') 73 | 74 | @property 75 | def message(self): 76 | return 'test message' 77 | 78 | @property 79 | def path(self): 80 | return 'Query.path' 81 | 82 | change = Testchange() 83 | print(repr(change)) 84 | assert repr(change) == ( 85 | "Change(" 86 | "criticality=Criticality(level=CriticalityLevel.NonBreaking, reason=this is a safe change), " 87 | "message='test message', " 88 | "path='Query.path')" 89 | ) 90 | -------------------------------------------------------------------------------- /schemadiff/diff/directive.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes.directive import ( 2 | DirectiveDescriptionChanged, 3 | DirectiveLocationsChanged, 4 | DirectiveArgumentAdded, 5 | DirectiveArgumentRemoved, 6 | DirectiveArgumentTypeChanged, 7 | DirectiveArgumentDefaultChanged, 8 | DirectiveArgumentDescriptionChanged, 9 | ) 10 | 11 | 12 | class Directive: 13 | def __init__(self, old_directive, new_directive): 14 | self.old_directive = old_directive 15 | self.new_directive = new_directive 16 | self.old_arguments = set(old_directive.args) 17 | self.new_arguments = set(new_directive.args) 18 | 19 | def diff(self): 20 | changes = [] 21 | if self.old_directive.description != self.new_directive.description: 22 | changes.append(DirectiveDescriptionChanged(self.old_directive, self.new_directive)) 23 | if self.old_directive.locations != self.new_directive.locations: 24 | changes.append(DirectiveLocationsChanged( 25 | self.new_directive, self.old_directive.locations, self.new_directive.locations 26 | )) 27 | 28 | removed = self.old_arguments - self.new_arguments 29 | added = self.new_arguments - self.old_arguments 30 | changes.extend(DirectiveArgumentAdded(self.new_directive, argument_name, self.new_directive.args[argument_name]) 31 | for argument_name in added) 32 | changes.extend(DirectiveArgumentRemoved(self.new_directive, argument_name, self.old_directive.args[argument_name]) 33 | for argument_name in removed) 34 | 35 | for arg_name in self.old_arguments & self.new_arguments: 36 | old_arg = self.old_directive.args[arg_name] 37 | new_arg = self.new_directive.args[arg_name] 38 | changes += DirectiveArgument(self.new_directive, arg_name, old_arg, new_arg).diff() 39 | 40 | return changes 41 | 42 | 43 | class DirectiveArgument: 44 | def __init__(self, directive, arg_name, old_arg, new_arg): 45 | self.directive = directive 46 | self.arg_name = arg_name 47 | self.old_arg = old_arg 48 | self.new_arg = new_arg 49 | 50 | def diff(self): 51 | changes = [] 52 | if str(self.old_arg.type) != str(self.new_arg.type): 53 | changes.append(DirectiveArgumentTypeChanged( 54 | self.directive, self.arg_name, self.old_arg.type, self.new_arg.type 55 | )) 56 | if self.old_arg.default_value != self.new_arg.default_value: 57 | changes.append(DirectiveArgumentDefaultChanged( 58 | self.directive, self.arg_name, self.old_arg.default_value, self.new_arg.default_value 59 | )) 60 | if self.old_arg.description != self.new_arg.description: 61 | changes.append(DirectiveArgumentDescriptionChanged( 62 | self.directive, self.arg_name, self.old_arg.description, self.new_arg.description 63 | )) 64 | 65 | return changes 66 | -------------------------------------------------------------------------------- /schemadiff/changes/enum.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes import Change, Criticality 2 | 3 | 4 | class EnumValueAdded(Change): 5 | def __init__(self, enum, value): 6 | self.criticality = Criticality.dangerous( 7 | "Adding an enum value may break existing clients that were not " 8 | "programming defensively against an added case when querying an enum." 9 | ) 10 | self.enum = enum 11 | self.value = value 12 | self.description = enum.values[value].description 13 | 14 | @property 15 | def message(self): 16 | return f"Enum value `{self.value}` was added to `{self.enum.name}` enum" 17 | 18 | @property 19 | def path(self): 20 | return f"{self.enum.name}.{self.value}" 21 | 22 | 23 | class EnumValueRemoved(Change): 24 | def __init__(self, enum, value): 25 | self.criticality = Criticality.breaking( 26 | "Removing an enum value will break existing queries that use this enum value" 27 | ) 28 | self.enum = enum 29 | self.value = value 30 | 31 | @property 32 | def message(self): 33 | return f"Enum value `{self.value}` was removed from `{self.enum.name}` enum" 34 | 35 | @property 36 | def path(self): 37 | return f"{self.enum.name}" 38 | 39 | 40 | class EnumValueDescriptionChanged(Change): 41 | 42 | criticality = Criticality.safe() 43 | 44 | def __init__(self, enum, name, old_value, new_value): 45 | self.enum = enum 46 | self.name = name 47 | self.old_value = old_value 48 | self.new_value = new_value 49 | 50 | @property 51 | def message(self): 52 | if not self.old_value.description: 53 | msg = f"Description for enum value `{self.name}` set to `{self.new_value.description}`" 54 | else: 55 | msg = ( 56 | f"Description for enum value `{self.name}` changed" 57 | f" from `{self.old_value.description}` to `{self.new_value.description}`" 58 | ) 59 | return msg 60 | 61 | @property 62 | def path(self): 63 | return f"{self.enum.name}.{self.name}" 64 | 65 | 66 | class EnumValueDeprecationReasonChanged(Change): 67 | 68 | criticality = Criticality.safe( 69 | "A deprecated field can still be used by clients and will give them time to adapt their queries" 70 | ) 71 | 72 | def __init__(self, enum, name, old_value, new_value): 73 | self.enum = enum 74 | self.name = name 75 | self.old_value = old_value 76 | self.new_value = new_value 77 | 78 | @property 79 | def message(self): 80 | if not self.old_value.deprecation_reason: 81 | msg = ( 82 | f"Enum value `{self.name}` was deprecated " 83 | f"with reason `{self.new_value.deprecation_reason}`" 84 | ) 85 | else: 86 | msg = ( 87 | f"Deprecation reason for enum value `{self.name}` changed " 88 | f"from `{self.old_value.deprecation_reason}` to `{self.new_value.deprecation_reason}`" 89 | ) 90 | return msg 91 | 92 | @property 93 | def path(self): 94 | return f"{self.enum.name}.{self.name}" -------------------------------------------------------------------------------- /schemadiff/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import argparse 3 | 4 | from schemadiff.allow_list import read_allowed_changes 5 | from schemadiff.diff.schema import Schema 6 | from schemadiff.schema_loader import SchemaLoader 7 | from schemadiff.formatting import print_diff, print_json 8 | from schemadiff.validation import rules_list, validate_changes 9 | 10 | 11 | def cli(): 12 | args = parse_args(sys.argv[1:]) 13 | return main(args) 14 | 15 | 16 | def parse_args(arguments): 17 | parser = argparse.ArgumentParser(description='Schema comparator') 18 | parser.add_argument('-o', '--old-schema', 19 | dest='old_schema', 20 | type=argparse.FileType('r', encoding='UTF-8'), 21 | help='Path to old graphql schema file', 22 | required=True) 23 | parser.add_argument('-n', '--new-schema', 24 | dest='new_schema', 25 | type=argparse.FileType('r', encoding='UTF-8'), 26 | help='Path to new graphql schema file', 27 | required=True) 28 | parser.add_argument('-j', '--as-json', 29 | action='store_true', 30 | help='Output a detailed summary of changes in json format', 31 | required=False) 32 | parser.add_argument('-a', '--allow-list', 33 | type=argparse.FileType('r', encoding='UTF-8'), 34 | help='Path to the allowed list of changes') 35 | parser.add_argument('-t', '--tolerant', 36 | action='store_true', 37 | help="Tolerant mode. Error out only if there's a breaking change but allow dangerous changes") 38 | parser.add_argument('-s', '--strict', 39 | action='store_true', 40 | help="Strict mode. Error out on dangerous and breaking changes.") 41 | parser.add_argument('-r', '--validation-rules', choices=rules_list(), nargs='*', 42 | help="Evaluate rules mode. Error out on changes that fail some validation rule.") 43 | 44 | return parser.parse_args(arguments) 45 | 46 | 47 | def main(args) -> int: 48 | # Load schemas from file path args 49 | old_schema = SchemaLoader.from_sdl(args.old_schema.read()) 50 | new_schema = SchemaLoader.from_sdl(args.new_schema.read()) 51 | args.old_schema.close() 52 | args.new_schema.close() 53 | if args.allow_list: 54 | allowed_changes = read_allowed_changes(args.allow_list.read()) 55 | args.allow_list.close() 56 | else: 57 | allowed_changes = {} 58 | 59 | diff = Schema(old_schema, new_schema).diff() 60 | validation_result = validate_changes(diff, args.validation_rules, allowed_changes) 61 | diff = [change for change in diff if change.checksum() not in allowed_changes] 62 | if args.as_json: 63 | print_json(diff) 64 | else: 65 | print_diff(diff) 66 | 67 | return exit_code(diff, args.strict, not validation_result.ok, args.tolerant) 68 | 69 | 70 | def exit_code(changes, strict, some_change_is_restricted, tolerant) -> int: 71 | exit_code = 0 72 | if strict and any(change.breaking or change.dangerous for change in changes): 73 | exit_code = 1 74 | elif tolerant and any(change.breaking for change in changes): 75 | exit_code = 2 76 | elif some_change_is_restricted: 77 | exit_code = 3 78 | 79 | return exit_code 80 | 81 | 82 | if __name__ == '__main__': 83 | sys.exit(cli()) 84 | -------------------------------------------------------------------------------- /tests/test_schema_integration.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from schemadiff.schema_loader import SchemaLoader 4 | from schemadiff.diff.schema import Schema 5 | 6 | TESTS_DATA = Path(__file__).parent / 'data' 7 | 8 | 9 | def test_compare_from_schema_string_sdl(): 10 | old_schema = SchemaLoader.from_file(TESTS_DATA / 'old_schema.gql') 11 | new_schema = SchemaLoader.from_file(TESTS_DATA / 'new_schema.gql') 12 | 13 | diff = Schema(old_schema, new_schema).diff() 14 | assert len(diff) == 38 15 | 16 | expected_changes = { 17 | 'Argument `arg: Int` added to `CType.a`', 18 | "Default value for argument `anotherArg` on `@yolo` directive changed from `Undefined` to `'Test'`", 19 | 'Default value for argument `arg` on field `CType.d` changed from `Undefined` to `10`', 20 | 'Default value for argument `arg` on field `WithArguments.b` changed from `1` to `2`', 21 | "Default value for input field `AInput.a` changed from `'1'` to `1`", 22 | 'Deprecation reason for enum value `F` changed from `Old` to `New`', 23 | 'Deprecation reason on field `CType.a` changed from `whynot` to `cuz`', 24 | 'Description for Input field `AInput.a` changed from `a` to `None`', 25 | 'Description for argument `a` on field `WithArguments.a` changed from `Meh` to `None`', 26 | 'Description for argument `someArg` on `@yolo` directive changed from `Included when true.` to `None`', 27 | 'Description for directive `@yolo` changed from `Old` to `None`', 28 | 'Description for type `Query` changed from `The Query Root of this schema` to `None`', 29 | 'Directive `@willBeRemoved` was removed', 30 | 'Directive `@yolo2` was added to use on `FIELD`', 31 | ( 32 | 'Directive locations of `@yolo` changed from `FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT` ' 33 | 'to `FIELD | FIELD_DEFINITION`' 34 | ), 35 | 'Enum value `C` was removed from `Options` enum', 36 | 'Enum value `D` was added to `Options` enum', 37 | 'Enum value `E` was deprecated with reason `No longer supported`', 38 | 'Field `anotherInterfaceField` was removed from interface `AnotherInterface`', 39 | 'Field `b` of type `Int` was added to interface `AnotherInterface`', 40 | 'Field `b` was added to object type `CType`', 41 | 'Field `c` was removed from object type `CType`', 42 | 'Input Field `b` removed from input type `AInput`', 43 | 'Input Field `c: String!` was added to input type `AInput`', 44 | 'Removed argument `anArg` from `Query.a`', 45 | 'Removed argument `willBeRemoved: Boolean!` from `@yolo` directive', 46 | 'Type `DType` was added', 47 | 'Type `WillBeRemoved` was removed', 48 | 'Type for argument `b` on field `WithArguments.a` changed from `String` to `String!`', 49 | 'Type for argument `someArg` on `@yolo` directive changed from `Boolean!` to `String!`', 50 | 'Union member `BType` was removed from `MyUnion` Union type', 51 | 'Union member `DType` was added to `MyUnion` Union type', 52 | '`AInput.a` type changed from `String` to `Int`', 53 | '`BType` kind changed from `OBJECT` to `INPUT OBJECT`', 54 | '`CType` implements new interface `AnInterface`', 55 | '`Query.a` description changed from `Just a simple string` to `None`', 56 | '`Query.b` type changed from `BType` to `Int!`', 57 | '`WithInterfaces` no longer implements interface `AnotherInterface`' 58 | } 59 | messages = [change.message for change in diff] 60 | for message in messages: 61 | assert message in expected_changes, f"{message} not found on expected messages" 62 | -------------------------------------------------------------------------------- /schemadiff/changes/field.py: -------------------------------------------------------------------------------- 1 | from graphql import is_non_null_type, GraphQLField 2 | 3 | from schemadiff.changes import Change, Criticality, is_safe_type_change 4 | 5 | 6 | class FieldDescriptionChanged(Change): 7 | 8 | criticality = Criticality.safe() 9 | 10 | def __init__(self, new_type, name, old_field, new_field): 11 | self.type = new_type 12 | self.field_name = name 13 | self.old_field = old_field 14 | self.new_field = new_field 15 | 16 | @property 17 | def message(self): 18 | return ( 19 | f"`{self.type.name}.{self.field_name}` description changed" 20 | f" from `{self.old_field.description}` to `{self.new_field.description}`" 21 | ) 22 | 23 | @property 24 | def path(self): 25 | return f'{self.type}.{self.field_name}' 26 | 27 | 28 | class FieldDeprecationReasonChanged(Change): 29 | 30 | criticality = Criticality.safe() 31 | 32 | def __init__(self, type_, name, old_field, new_field): 33 | self.type_ = type_ 34 | self.field_name = name 35 | self.old_field = old_field 36 | self.new_field = new_field 37 | 38 | @property 39 | def message(self): 40 | return ( 41 | f"Deprecation reason on field `{self.type_}.{self.field_name}` changed " 42 | f"from `{self.old_field.deprecation_reason}` to `{self.new_field.deprecation_reason}`" 43 | ) 44 | 45 | @property 46 | def path(self): 47 | return f"{self.type_}.{self.field_name}" 48 | 49 | 50 | class FieldTypeChanged(Change): 51 | 52 | def __init__(self, type_, field_name, old_field, new_field): 53 | self.criticality = Criticality.safe()\ 54 | if is_safe_type_change(old_field.type, new_field.type)\ 55 | else Criticality.breaking('Changing a field type will break queries that assume its type') 56 | self.type_ = type_ 57 | self.field_name = field_name 58 | self.old_field = old_field 59 | self.new_field = new_field 60 | 61 | @property 62 | def message(self): 63 | return ( 64 | f"`{self.type_}.{self.field_name}` type changed " 65 | f"from `{self.old_field.type}` to `{self.new_field.type}`" 66 | ) 67 | 68 | @property 69 | def path(self): 70 | return f"{self.type_}.{self.field_name}" 71 | 72 | 73 | class FieldArgumentAdded(Change): 74 | def __init__(self, parent, field_name: str, field: GraphQLField, argument_name, arg_type): 75 | self.criticality = Criticality.safe('Adding an optional argument is a safe change')\ 76 | if not is_non_null_type(arg_type.type)\ 77 | else Criticality.breaking("Adding a required argument to an existing field is a breaking " 78 | "change because it will break existing uses of this field") 79 | self.parent = parent 80 | self.field_name = field_name 81 | self.field = field 82 | self.argument_name = argument_name 83 | self.arg_type = arg_type 84 | 85 | @property 86 | def message(self): 87 | return ( 88 | f"Argument `{self.argument_name}: {self.arg_type.type}` " 89 | f"added to `{self.parent.name}.{self.field_name}`" 90 | ) 91 | 92 | @property 93 | def path(self): 94 | return f"{self.parent}.{self.field_name}" 95 | 96 | 97 | class FieldArgumentRemoved(Change): 98 | 99 | criticality = Criticality.breaking( 100 | "Removing a field argument will break queries that use this argument" 101 | ) 102 | 103 | def __init__(self, parent, field_name, argument_name): 104 | self.parent = parent 105 | self.field_name = field_name 106 | self.argument_name = argument_name 107 | 108 | @property 109 | def message(self): 110 | return ( 111 | f"Removed argument `{self.argument_name}` from `{self.parent.name}.{self.field_name}`" 112 | ) 113 | 114 | @property 115 | def path(self): 116 | return f"{self.parent}.{self.field_name}" 117 | -------------------------------------------------------------------------------- /schemadiff/changes/input.py: -------------------------------------------------------------------------------- 1 | from graphql import is_non_null_type 2 | 3 | from schemadiff.changes import Change, Criticality, is_safe_change_for_input_value 4 | 5 | 6 | class InputFieldAdded(Change): 7 | 8 | BREAKING_MSG = ( 9 | "Adding a non-null field to an existing input type will cause existing " 10 | "queries that use this input type to break because they will " 11 | "not provide a value for this new field." 12 | ) 13 | 14 | def __init__(self, input_object, field_name, field): 15 | self.criticality = ( 16 | Criticality.safe() 17 | if is_non_null_type(field.type) is False 18 | else Criticality.breaking(self.BREAKING_MSG) 19 | ) 20 | 21 | self.input_object = input_object 22 | self.field_name = field_name 23 | self.field = field 24 | 25 | @property 26 | def message(self): 27 | return f"Input Field `{self.field_name}: {self.field.type}` was added to input type `{self.input_object}`" 28 | 29 | @property 30 | def path(self): 31 | return f"{self.input_object}.{self.field_name}" 32 | 33 | 34 | class InputFieldRemoved(Change): 35 | 36 | criticality = Criticality.breaking( 37 | 'Removing an input field will break queries that use this input field.' 38 | ) 39 | 40 | def __init__(self, input_object, value): 41 | self.input_object = input_object 42 | self.value = value 43 | 44 | @property 45 | def message(self): 46 | return f"Input Field `{self.value}` removed from input type `{self.input_object}`" 47 | 48 | @property 49 | def path(self): 50 | return f"{self.input_object}.{self.value}" 51 | 52 | 53 | class InputFieldDescriptionChanged(Change): 54 | 55 | criticality = Criticality.safe() 56 | 57 | def __init__(self, input_, name, new_field, old_field): 58 | self.input_ = input_ 59 | self.name = name 60 | self.new_field = new_field 61 | self.old_field = old_field 62 | 63 | @property 64 | def message(self): 65 | return ( 66 | f"Description for Input field `{self.input_.name}.{self.name}` " 67 | f"changed from `{self.old_field.description}` to `{self.new_field.description}`" 68 | ) 69 | 70 | @property 71 | def path(self): 72 | return f"{self.input_.name}.{self.name}" 73 | 74 | 75 | class InputFieldDefaultChanged(Change): 76 | 77 | criticality = Criticality.dangerous( 78 | "Changing the default value for an argument may change the runtime " 79 | "behaviour of a field if it was never provided." 80 | ) 81 | 82 | def __init__(self, input_, name, new_field, old_field): 83 | self.input_ = input_ 84 | self.name = name 85 | self.new_field = new_field 86 | self.old_field = old_field 87 | 88 | @property 89 | def message(self): 90 | return ( 91 | f"Default value for input field `{self.input_.name}.{self.name}` " 92 | f"changed from `{self.old_field.default_value!r}` to `{self.new_field.default_value!r}`" 93 | ) 94 | 95 | @property 96 | def path(self): 97 | return f"{self.input_.name}.{self.name}" 98 | 99 | 100 | class InputFieldTypeChanged(Change): 101 | def __init__(self, input_, name, new_field, old_field): 102 | self.criticality = ( 103 | Criticality.safe() 104 | if is_safe_change_for_input_value(old_field.type, new_field.type) 105 | else Criticality.breaking( 106 | "Changing the type of an input field can break existing queries that use this field" 107 | ) 108 | ) 109 | self.input_ = input_ 110 | self.name = name 111 | self.new_field = new_field 112 | self.old_field = old_field 113 | 114 | @property 115 | def message(self): 116 | return ( 117 | f"`{self.input_.name}.{self.name}` type changed from " 118 | f"`{self.old_field.type}` to `{self.new_field.type}`" 119 | ) 120 | 121 | @property 122 | def path(self): 123 | return f"{self.input_.name}.{self.name}" -------------------------------------------------------------------------------- /schemadiff/changes/interface.py: -------------------------------------------------------------------------------- 1 | from schemadiff.changes import Change, Criticality 2 | 3 | 4 | class InterfaceFieldAdded(Change): 5 | 6 | criticality = Criticality.dangerous( 7 | "Adding an interface to an object type may break existing clients " 8 | "that were not programming defensively against a new possible type." 9 | ) 10 | 11 | def __init__(self, interface, field_name, field): 12 | self.interface = interface 13 | self.field_name = field_name 14 | self.field = field 15 | 16 | @property 17 | def message(self): 18 | return f"Field `{self.field_name}` of type `{self.field.type}` was added to interface `{self.interface}`" 19 | 20 | @property 21 | def path(self): 22 | return f"{self.interface.name}.{self.field_name}" 23 | 24 | 25 | class InterfaceFieldRemoved(Change): 26 | 27 | criticality = Criticality.dangerous( 28 | "Removing an interface field can break existing " 29 | "queries that use this in a fragment spread." 30 | ) 31 | 32 | def __init__(self, interface, field_name): 33 | self.interface = interface 34 | self.field_name = field_name 35 | 36 | @property 37 | def message(self): 38 | return f"Field `{self.field_name}` was removed from interface `{self.interface}`" 39 | 40 | @property 41 | def path(self): 42 | return f"{self.interface.name}.{self.field_name}" 43 | 44 | 45 | class AbstractInterfaceChange(Change): 46 | def __init__(self, interface, field_name, old_field, new_field): 47 | self.interface = interface 48 | self.field_name = field_name 49 | self.old_field = old_field 50 | self.new_field = new_field 51 | 52 | @property 53 | def path(self): 54 | return f"{self.interface.name}.{self.field_name}" 55 | 56 | 57 | class InterfaceFieldTypeChanged(AbstractInterfaceChange): 58 | 59 | @property 60 | def message(self): 61 | return ( 62 | f"Field `{self.interface.name}.{self.field_name}` type " 63 | f"changed from `{self.old_field.type}` to `{self.new_field.type}`" 64 | ) 65 | 66 | 67 | class NewInterfaceImplemented(Change): 68 | 69 | criticality = Criticality.dangerous( 70 | "Adding an interface to an object type may break existing clients " 71 | "that were not programming defensively against a new possible type." 72 | ) 73 | 74 | def __init__(self, interface, type_): 75 | self.interface = interface 76 | self.type_ = type_ 77 | 78 | @property 79 | def message(self): 80 | return f"`{self.type_.name}` implements new interface `{self.interface.name}`" 81 | 82 | @property 83 | def path(self): 84 | return f"{self.type_}" 85 | 86 | 87 | class DroppedInterfaceImplementation(Change): 88 | 89 | criticality = Criticality.breaking( 90 | "Removing an interface from an object type can break existing queries " 91 | "that use this in a fragment spread." 92 | ) 93 | 94 | def __init__(self, interface, type_): 95 | self.interface = interface 96 | self.type_ = type_ 97 | 98 | @property 99 | def message(self): 100 | return f"`{self.type_.name}` no longer implements interface `{self.interface.name}`" 101 | 102 | @property 103 | def path(self): 104 | return f"{self.type_}" 105 | 106 | 107 | class InterfaceFieldDescriptionChanged(AbstractInterfaceChange): 108 | criticality = Criticality.safe() 109 | 110 | @property 111 | def message(self): 112 | return ( 113 | f"`{self.interface.name}.{self.field_name}` description changed " 114 | f"from `{self.old_field.description}` to `{self.new_field.description}`" 115 | ) 116 | 117 | 118 | class InterfaceFieldDeprecationReasonChanged(AbstractInterfaceChange): 119 | criticality = Criticality.breaking('Breaking change') # TODO: Improve this logic to check if it was deprecated before 120 | 121 | @property 122 | def message(self): 123 | return ( 124 | f"`{self.interface.name}.{self.field_name}` deprecation reason changed " 125 | f"from `{self.old_field.deprecation_reason}` to `{self.new_field.deprecation_reason}`" 126 | ) 127 | -------------------------------------------------------------------------------- /tests/diff/test_enum.py: -------------------------------------------------------------------------------- 1 | from graphql import build_schema as schema 2 | 3 | from schemadiff.changes import Criticality 4 | from schemadiff.diff.schema import Schema 5 | 6 | 7 | def test_enum_value_removed(): 8 | a = schema(""" 9 | type Query { 10 | a: String 11 | } 12 | enum Letters { 13 | A 14 | B 15 | } 16 | """) 17 | b = schema(""" 18 | type Query { 19 | a: String 20 | } 21 | enum Letters { 22 | A 23 | } 24 | """) 25 | diff = Schema(a, b).diff() 26 | assert len(diff) == 1 27 | change = diff[0] 28 | assert change.message == "Enum value `B` was removed from `Letters` enum" 29 | assert change.path == 'Letters' 30 | assert change.criticality == Criticality.breaking( 31 | 'Removing an enum value will break existing queries that use this enum value' 32 | ) 33 | 34 | 35 | def test_enums_added(): 36 | a = schema(""" 37 | type Query { 38 | a: String 39 | } 40 | enum Letters { 41 | A 42 | B 43 | } 44 | """) 45 | b = schema(""" 46 | type Query { 47 | a: String 48 | } 49 | enum Letters { 50 | A 51 | B 52 | C 53 | D 54 | } 55 | """) 56 | diff = Schema(a, b).diff() 57 | assert len(diff) == 2 58 | expected_diff = { 59 | "Enum value `C` was added to `Letters` enum", 60 | "Enum value `D` was added to `Letters` enum", 61 | } 62 | expected_paths = {'Letters.C', 'Letters.D'} 63 | for change in diff: 64 | assert change.message in expected_diff 65 | assert change.path in expected_paths 66 | assert change.criticality == Criticality.dangerous( 67 | "Adding an enum value may break existing clients that " 68 | "were not programming defensively against an added case when querying an enum." 69 | ) 70 | 71 | 72 | def test_deprecated_enum_value(): 73 | a = schema(""" 74 | type Query { 75 | a: Int 76 | } 77 | enum Letters { 78 | A 79 | B 80 | } 81 | """) 82 | b = schema(""" 83 | type Query { 84 | a: Int 85 | } 86 | enum Letters { 87 | A 88 | B @deprecated(reason: "Changed the alphabet") 89 | } 90 | """) 91 | diff = Schema(a, b).diff() 92 | assert len(diff) == 1 93 | assert diff[0].message == "Enum value `B` was deprecated with reason `Changed the alphabet`" 94 | assert diff[0].path == 'Letters.B' 95 | assert diff[0].criticality == Criticality.safe( 96 | "A deprecated field can still be used by clients and will give them time to adapt their queries" 97 | ) 98 | 99 | 100 | def test_deprecated_reason_changed(): 101 | a = schema(""" 102 | type Query { 103 | a: Int 104 | } 105 | enum Letters { 106 | A 107 | B @deprecated(reason: "a reason") 108 | } 109 | """) 110 | b = schema(""" 111 | type Query { 112 | a: Int 113 | } 114 | enum Letters { 115 | A 116 | B @deprecated(reason: "a new reason") 117 | } 118 | """) 119 | diff = Schema(a, b).diff() 120 | assert len(diff) == 1 121 | assert diff[0].message == ( 122 | "Deprecation reason for enum value `B` changed from `a reason` to `a new reason`" 123 | ) 124 | assert diff[0].path == 'Letters.B' 125 | assert diff[0].criticality == Criticality.safe( 126 | "A deprecated field can still be used by clients and will give them time to adapt their queries" 127 | ) 128 | 129 | 130 | def test_description_added(): 131 | a = schema(''' 132 | type Query { 133 | a: Int 134 | } 135 | enum Letters { 136 | A 137 | } 138 | ''') 139 | b = schema(''' 140 | type Query { 141 | a: Int 142 | } 143 | enum Letters { 144 | """My new description""" 145 | A 146 | } 147 | ''') 148 | diff = Schema(a, b).diff() 149 | assert len(diff) == 1 150 | assert diff[0].message == ( 151 | "Description for enum value `A` set to `My new description`" 152 | ) 153 | assert diff[0].path == 'Letters.A' 154 | assert diff[0].criticality == Criticality.safe() 155 | 156 | 157 | def test_description_changed(): 158 | a = schema(''' 159 | type Query { 160 | a: Int 161 | } 162 | enum Letters { 163 | """My description""" 164 | A 165 | } 166 | ''') 167 | b = schema(''' 168 | type Query { 169 | a: Int 170 | } 171 | enum Letters { 172 | """My new description""" 173 | A 174 | } 175 | ''') 176 | diff = Schema(a, b).diff() 177 | assert len(diff) == 1 178 | assert diff[0].message == ( 179 | "Description for enum value `A` changed from `My description` to `My new description`" 180 | ) 181 | assert diff[0].path == 'Letters.A' 182 | assert diff[0].criticality == Criticality.safe() 183 | -------------------------------------------------------------------------------- /tests/diff/test_interface.py: -------------------------------------------------------------------------------- 1 | from graphql import build_schema as schema 2 | 3 | from schemadiff.changes import Criticality 4 | from schemadiff.diff.schema import Schema 5 | 6 | 7 | def test_interface_no_changes(): 8 | a = schema(""" 9 | interface Person { 10 | name: String 11 | age: Int 12 | } 13 | """) 14 | b = schema(""" 15 | interface Person { 16 | age: Int 17 | name: String 18 | } 19 | """) 20 | diff = Schema(a, b).diff() 21 | assert not diff 22 | 23 | 24 | def test_interface_field_added_and_removed(): 25 | a = schema(""" 26 | interface Person { 27 | name: String 28 | age: Int 29 | } 30 | """) 31 | b = schema(""" 32 | interface Person { 33 | name: String 34 | age: Int 35 | favorite_number: Float 36 | } 37 | """) 38 | 39 | diff = Schema(a, b).diff() 40 | assert diff and len(diff) == 1 41 | assert diff[0].message == "Field `favorite_number` of type `Float` was added to interface `Person`" 42 | assert diff[0].path == 'Person.favorite_number' 43 | assert diff[0].criticality == Criticality.dangerous( 44 | 'Adding an interface to an object type may break existing clients ' 45 | 'that were not programming defensively against a new possible type.' 46 | ) 47 | 48 | diff = Schema(b, a).diff() 49 | assert diff[0].message == "Field `favorite_number` was removed from interface `Person`" 50 | assert diff[0].path == 'Person.favorite_number' 51 | assert diff[0].criticality == Criticality.dangerous( 52 | 'Removing an interface field can break existing queries that use this in a fragment spread.' 53 | ) 54 | 55 | 56 | def test_interface_field_type_changed(): 57 | a = schema(""" 58 | interface Person { 59 | age: Int 60 | } 61 | """) 62 | b = schema(""" 63 | interface Person { 64 | age: Float! 65 | } 66 | """) 67 | diff = Schema(a, b).diff() 68 | assert diff and len(diff) == 1 69 | assert diff[0].message == "`Person.age` type changed from `Int` to `Float!`" 70 | assert diff[0].path == 'Person.age' 71 | assert diff[0].criticality == Criticality.breaking( 72 | 'Changing a field type will break queries that assume its type' 73 | ) 74 | 75 | 76 | def test_interface_field_description_changed(): 77 | a = schema(''' 78 | interface Person { 79 | """desc""" 80 | age: Int 81 | } 82 | ''') 83 | b = schema(''' 84 | interface Person { 85 | """other desc""" 86 | age: Int 87 | } 88 | ''') 89 | diff = Schema(a, b).diff() 90 | assert diff and len(diff) == 1 91 | assert diff[0].message == "`Person.age` description changed from `desc` to `other desc`" 92 | assert diff[0].path == 'Person.age' 93 | assert diff[0].criticality == Criticality.safe() 94 | 95 | 96 | def test_interface_field_deprecation_reason_changed(): 97 | a = schema(''' 98 | interface Person { 99 | age: Int @deprecated 100 | } 101 | ''') # A default deprecation reason is appended on deprecation 102 | b = schema(''' 103 | interface Person { 104 | age: Int @deprecated(reason: "my reason") 105 | } 106 | ''') 107 | diff = Schema(a, b).diff() 108 | assert diff and len(diff) == 1 109 | assert diff[0].message == ( 110 | "Deprecation reason on field `Person.age` changed from `No longer supported` to `my reason`" 111 | ) 112 | assert diff[0].path == 'Person.age' 113 | assert diff[0].criticality == Criticality.safe() 114 | 115 | 116 | def test_type_implements_new_interface(): 117 | a = schema(""" 118 | interface InterfaceA { 119 | a: Int 120 | } 121 | type MyType implements InterfaceA { 122 | a: Int 123 | } 124 | """) 125 | b = schema(""" 126 | interface InterfaceA { 127 | a: Int 128 | } 129 | interface InterfaceB { 130 | b: String 131 | } 132 | type MyType implements InterfaceA & InterfaceB { 133 | a: Int 134 | b: String 135 | } 136 | """) 137 | diff = Schema(a, b).diff() 138 | assert diff and len(diff) == 3 139 | expected_diff = { 140 | "Field `b` was added to object type `MyType`", 141 | "Type `InterfaceB` was added", 142 | "`MyType` implements new interface `InterfaceB`" 143 | } 144 | expected_paths = {'InterfaceB', 'MyType', 'MyType.b'} 145 | for change in diff: 146 | assert change.message in expected_diff 147 | assert change.path in expected_paths 148 | 149 | diff = Schema(b, a).diff() 150 | assert diff and len(diff) == 3 151 | expected_diff = { 152 | "Field `b` was removed from object type `MyType`", 153 | "Type `InterfaceB` was removed", 154 | "`MyType` no longer implements interface `InterfaceB`" 155 | } 156 | expected_paths = {'InterfaceB', 'MyType', 'MyType.b'} 157 | for change in diff: 158 | assert change.message in expected_diff 159 | assert change.path in expected_paths 160 | -------------------------------------------------------------------------------- /tests/test_changes_as_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from operator import itemgetter 3 | 4 | from schemadiff.changes import Criticality, Change 5 | from schemadiff.diff.schema import Schema 6 | from schemadiff.formatting import json_dump_changes, changes_to_dict 7 | from schemadiff.schema_loader import SchemaLoader as SchemaLoad 8 | from tests.test_schema_loading import TESTS_DATA 9 | 10 | 11 | def test_dict_representation(): 12 | class MyChange(Change): 13 | criticality = Criticality.breaking('If lorem is ipsum the world might end') 14 | 15 | @property 16 | def message(self): 17 | return 'Lorem should not be ipsum' 18 | 19 | @property 20 | def path(self): 21 | return 'Lorem.Ipsum' 22 | 23 | the_change = MyChange() 24 | expected_repr = { 25 | 'message': 'Lorem should not be ipsum', 26 | 'path': 'Lorem.Ipsum', 27 | 'is_safe_change': False, 28 | 'criticality': { 29 | 'level': 'BREAKING', 30 | 'reason': 'If lorem is ipsum the world might end', 31 | }, 32 | 'checksum': 'be519517bbcc5dd41ff526719e0764ec', 33 | } 34 | assert the_change.to_dict() == expected_repr 35 | 36 | 37 | def test_json_representation(): 38 | class MyChange(Change): 39 | criticality = Criticality.dangerous('This might be dangerous') 40 | 41 | @property 42 | def message(self): 43 | return 'Unagi' 44 | 45 | @property 46 | def path(self): 47 | return 'Unagi.Friends' 48 | 49 | the_change = MyChange() 50 | expected_dict = { 51 | 'message': 'Unagi', 52 | 'path': 'Unagi.Friends', 53 | 'is_safe_change': False, 54 | 'criticality': { 55 | 'level': 'DANGEROUS', 56 | 'reason': 'This might be dangerous', 57 | }, 58 | 'checksum': '6dde00418330279921218878d9ce7d38', 59 | } 60 | assert json.loads(the_change.to_json()) == expected_dict 61 | 62 | 63 | def test_safe_change(): 64 | class MyChange(Change): 65 | criticality = Criticality.safe('Hello') 66 | 67 | @property 68 | def message(self): 69 | return '' 70 | 71 | @property 72 | def path(self): 73 | return None 74 | 75 | the_change = MyChange() 76 | expected_dict = { 77 | 'message': '', 78 | 'path': None, 79 | 'is_safe_change': True, 80 | 'criticality': { 81 | 'level': 'NON_BREAKING', 82 | 'reason': 'Hello', 83 | }, 84 | 'checksum': 'd41d8cd98f00b204e9800998ecf8427e', 85 | } 86 | assert expected_dict == the_change.to_dict() 87 | assert json.loads(the_change.to_json()) == expected_dict 88 | 89 | 90 | def test_json_dump_changes(): 91 | old = SchemaLoad.from_file(TESTS_DATA / 'simple_schema.gql') 92 | new = SchemaLoad.from_file(TESTS_DATA / 'simple_schema_dangerous_and_breaking.gql') 93 | 94 | changes = Schema(old, new).diff() 95 | expected_changes = [ 96 | { 97 | 'criticality': { 98 | 'level': 'NON_BREAKING', 99 | 'reason': "This change won't break any preexisting query" 100 | }, 101 | 'is_safe_change': True, 102 | 'message': 'Field `c` was added to object type `Query`', 103 | 'path': 'Query.c', 104 | 'checksum': '1e3b776bda2dd8b11804e7341bb8b2d1', 105 | }, 106 | { 107 | 'criticality': { 108 | 'level': 'BREAKING', 109 | 'reason': ( 110 | 'Removing a field is a breaking change. It is preferred to deprecate the field before removing it.' 111 | ) 112 | }, 113 | 'is_safe_change': False, 114 | 'message': 'Field `a` was removed from object type `Query`', 115 | 'path': 'Query.a', 116 | 'checksum': '5fba3d6ffc43c6769c6959ce5cb9b1c8', 117 | }, 118 | { 119 | 'criticality': { 120 | 'level': 'DANGEROUS', 121 | 'reason': ( 122 | 'Changing the default value for an argument may ' 123 | 'change the runtime behaviour of a field if it was never provided.' 124 | ) 125 | }, 126 | 'is_safe_change': False, 127 | 'message': 'Default value for argument `x` on field `Field.calculus` changed from `0` to `1`', 128 | 'path': 'Field.calculus', 129 | 'checksum': '8890b911d44b2ead0dceeae066d98617', 130 | } 131 | ] 132 | 133 | changes_result = changes_to_dict(changes) 134 | by_path = itemgetter('path') 135 | assert sorted(changes_result, key=by_path) == sorted(expected_changes, key=by_path) 136 | 137 | # Assert the json dump has a 32-char checksum and the changes attributes as expected 138 | json_changes = json.loads(json_dump_changes(changes)) 139 | assert all(len(change['checksum']) == 32 for change in json_changes) 140 | assert sorted(json_changes, key=by_path) == sorted(expected_changes, key=by_path) 141 | -------------------------------------------------------------------------------- /schemadiff/validation_rules/__init__.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from abc import ABC, abstractmethod 3 | from typing import List, Set 4 | 5 | from graphql import GraphQLObjectType 6 | 7 | from schemadiff.changes.enum import EnumValueAdded, EnumValueDescriptionChanged 8 | from schemadiff.changes.field import FieldDescriptionChanged, FieldArgumentAdded 9 | from schemadiff.changes.object import ObjectTypeFieldAdded 10 | from schemadiff.changes.type import AddedType, TypeDescriptionChanged 11 | 12 | 13 | class ValidationRule(ABC): 14 | """Abstract class for creating schema Validation Rules.""" 15 | name: str = "" 16 | 17 | def __init__(self, change): 18 | self.change = change 19 | 20 | @abstractmethod 21 | def is_valid(self) -> bool: 22 | """Evaluate the change regarding the rule class defined. 23 | 24 | Returns: 25 | bool: True if change is valid, False otherwise 26 | """ 27 | 28 | @property 29 | @abstractmethod 30 | def message(self) -> str: 31 | """Formatted change message""" 32 | 33 | @classmethod 34 | def get_subclasses_by_names(cls, names: List[str]) -> Set[typing.Type['ValidationRule']]: 35 | if not names: 36 | return set() 37 | return {subclass for subclass in cls.__subclasses__() if subclass.name in names} 38 | 39 | @classmethod 40 | def get_rules_list(cls) -> Set[str]: 41 | return {subclass.name for subclass in cls.__subclasses__()} 42 | 43 | 44 | class AddTypeWithoutDescription(ValidationRule): 45 | """Restrict adding new GraphQL types without entering 46 | a non-empty description.""" 47 | name = "add-type-without-description" 48 | 49 | def is_valid(self) -> bool: 50 | EMPTY = (None, "") 51 | if not isinstance(self.change, AddedType): 52 | return True 53 | 54 | added_type = self.change.type 55 | type_has_description = added_type.description not in EMPTY 56 | 57 | if isinstance(added_type, GraphQLObjectType): 58 | all_its_fields_have_description = all( 59 | field.description not in EMPTY 60 | for field in added_type.fields.values() 61 | ) 62 | return type_has_description and all_its_fields_have_description 63 | else: 64 | return type_has_description 65 | 66 | @property 67 | def message(self): 68 | return f"{self.change.message} without a description for {self.change.path} (rule: `{self.name}`)." 69 | 70 | 71 | class RemoveTypeDescription(ValidationRule): 72 | """Restrict removing the description from an existing 73 | GraphQL type.""" 74 | name = "remove-type-description" 75 | 76 | def is_valid(self) -> bool: 77 | if isinstance(self.change, TypeDescriptionChanged): 78 | return self.change.new_desc not in (None, "") 79 | return True 80 | 81 | @property 82 | def message(self): 83 | return ( 84 | f"Description for type `{self.change.type}` was " 85 | f"removed (rule: `{self.name}`)" 86 | ) 87 | 88 | 89 | class AddFieldWithoutDescription(ValidationRule): 90 | """Restrict adding fields without description.""" 91 | name = "add-field-without-description" 92 | 93 | def is_valid(self) -> bool: 94 | if isinstance(self.change, ObjectTypeFieldAdded): 95 | return self.change.description not in (None, "") 96 | return True 97 | 98 | @property 99 | def message(self): 100 | return f"{self.change.message} without a description for {self.change.path} (rule: `{self.name}`)." 101 | 102 | 103 | class RemoveFieldDescription(ValidationRule): 104 | """Restrict removing field description.""" 105 | name = "remove-field-description" 106 | 107 | def is_valid(self) -> bool: 108 | if isinstance(self.change, FieldDescriptionChanged): 109 | return self.change.new_field.description not in (None, "") 110 | return True 111 | 112 | @property 113 | def message(self): 114 | return ( 115 | f"`{self.change.type.name}.{self.change.field_name}` description was " 116 | f"removed (rule: `{self.name}`)" 117 | ) 118 | 119 | 120 | class AddEnumValueWithoutDescription(ValidationRule): 121 | """Restrict adding enum value without description.""" 122 | name = "add-enum-value-without-description" 123 | 124 | def is_valid(self) -> bool: 125 | if isinstance(self.change, EnumValueAdded): 126 | return self.change.description not in (None, "") 127 | return True 128 | 129 | @property 130 | def message(self): 131 | return f"{self.change.message} without a description (rule: `{self.name}`)" 132 | 133 | 134 | class RemoveEnumValueDescription(ValidationRule): 135 | """Restrict adding enum value without description.""" 136 | name = "remove-enum-value-description" 137 | 138 | def is_valid(self) -> bool: 139 | if isinstance(self.change, EnumValueDescriptionChanged): 140 | return self.change.new_value.description not in (None, "") 141 | return True 142 | 143 | @property 144 | def message(self): 145 | return f"Description for enum value `{self.change.name}` was removed (rule: `{self.name}`)" 146 | 147 | 148 | -------------------------------------------------------------------------------- /tests/diff/test_argument.py: -------------------------------------------------------------------------------- 1 | from graphql import build_schema as schema 2 | 3 | from schemadiff.changes import CriticalityLevel, Criticality 4 | from schemadiff.diff.schema import Schema 5 | 6 | SAFE_CHANGE_MSG = "This change won't break any preexisting query" 7 | 8 | 9 | def test_argument_type_changed(): 10 | a = schema(""" 11 | type Math { 12 | sum(arg1: Int, arg2: Int): Int 13 | } 14 | """) 15 | b = schema(""" 16 | type Math { 17 | sum(arg1: Float, arg2: Int): Int 18 | } 19 | """) 20 | 21 | diff = Schema(a, b).diff() 22 | assert len(diff) == 1 23 | assert diff[0].message == ( 24 | "Type for argument `arg1` on field `Math.sum` changed from `Int` to `Float`" 25 | ) 26 | assert diff[0].path == 'Math.sum' 27 | assert diff[0].criticality.level == CriticalityLevel.Breaking 28 | assert diff[0].criticality.reason == ( 29 | "Changing the type of a field's argument can break existing queries that use this argument." 30 | ) 31 | 32 | 33 | def test_argument_add_non_nullable_field(): 34 | a = schema(""" 35 | type Field { 36 | exp(a: Int): Int 37 | } 38 | """) 39 | b = schema(""" 40 | type Field { 41 | exp(a: Int, power: Int!): Int 42 | } 43 | """) 44 | diff = Schema(a, b).diff() 45 | assert len(diff) == 1 46 | change = diff[0] 47 | assert change.message == ( 48 | "Argument `power: Int!` added to `Field.exp`" 49 | ) 50 | assert change.path == 'Field.exp' 51 | assert change.criticality.level == CriticalityLevel.Breaking 52 | assert change.criticality.reason == ( 53 | "Adding a required argument to an existing field is a " 54 | "breaking change because it will break existing uses of this field" 55 | ) 56 | 57 | 58 | def test_argument_added_removed(): 59 | a = schema(""" 60 | type Field { 61 | exp(a: Int): Int 62 | } 63 | """) 64 | b = schema(""" 65 | type Field { 66 | exp(a: Int, power: Int): Int 67 | } 68 | """) 69 | 70 | diff = Schema(a, b).diff() 71 | assert len(diff) == 1 72 | assert diff[0].message == ( 73 | "Argument `power: Int` added to `Field.exp`" 74 | ) 75 | assert diff[0].path == 'Field.exp' 76 | assert diff[0].criticality.level == CriticalityLevel.NonBreaking 77 | assert diff[0].criticality.reason == "Adding an optional argument is a safe change" 78 | 79 | diff = Schema(b, a).diff() 80 | assert diff[0].message == ( 81 | "Removed argument `power` from `Field.exp`" 82 | ) 83 | assert diff[0].path == 'Field.exp' 84 | assert diff[0].criticality.level == CriticalityLevel.Breaking 85 | assert diff[0].criticality.reason == "Removing a field argument will break queries that use this argument" 86 | 87 | 88 | def test_argument_description_changed(): 89 | a = schema(''' 90 | input Precision { 91 | """result precision""" 92 | decimals: Int 93 | } 94 | type Math { 95 | sum(arg1: Precision): Float 96 | } 97 | ''') 98 | b = schema(''' 99 | input Precision { 100 | """new desc""" 101 | decimals: Int 102 | } 103 | type Math { 104 | sum(arg1: Precision): Float 105 | } 106 | ''') 107 | 108 | diff = Schema(a, b).diff() 109 | assert len(diff) == 1 110 | assert diff[0].message == ( 111 | "Description for Input field `Precision.decimals` " 112 | "changed from `result precision` to `new desc`" 113 | ) 114 | assert diff[0].path == 'Precision.decimals' 115 | assert diff[0].criticality.level == CriticalityLevel.NonBreaking 116 | assert diff[0].criticality.reason == SAFE_CHANGE_MSG 117 | 118 | 119 | def test_argument_description_of_inner_type_changed(): 120 | a = schema(''' 121 | type TypeWithArgs { 122 | field( 123 | """abc""" 124 | a: Int 125 | b: String! 126 | ): String 127 | } 128 | ''') 129 | b = schema(''' 130 | type TypeWithArgs { 131 | field( 132 | """zzz wxYZ""" 133 | a: Int 134 | b: String! 135 | ): String 136 | } 137 | ''') 138 | 139 | diff = Schema(a, b).diff() 140 | assert diff and len(diff) == 1 141 | assert diff[0].message == ( 142 | "Description for argument `a` on field `TypeWithArgs.field` " 143 | "changed from `abc` to `zzz wxYZ`" 144 | ) 145 | assert diff[0].path == 'TypeWithArgs.field' 146 | assert diff[0].criticality == Criticality.safe(SAFE_CHANGE_MSG) 147 | 148 | 149 | def test_argument_default_value_changed(): 150 | a = schema(""" 151 | type Field { 152 | exp(base: Int=0): Int 153 | } 154 | """) 155 | b = schema(""" 156 | type Field { 157 | exp(base: Int=1): Int 158 | } 159 | """) 160 | 161 | diff = Schema(a, b).diff() 162 | assert len(diff) == 1 163 | assert diff[0].message == ( 164 | "Default value for argument `base` on field `Field.exp` changed from `0` to `1`" 165 | ) 166 | assert diff[0].path == 'Field.exp' 167 | assert diff[0].criticality.level == CriticalityLevel.Dangerous 168 | assert diff[0].criticality.reason == ( 169 | "Changing the default value for an argument may change the " 170 | "runtime behaviour of a field if it was never provided." 171 | ) 172 | -------------------------------------------------------------------------------- /schemadiff/changes/__init__.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | from abc import abstractmethod, ABC 4 | from enum import Enum 5 | from typing import Optional 6 | 7 | from attr import dataclass 8 | from graphql import is_wrapping_type, is_non_null_type, is_list_type 9 | 10 | 11 | class CriticalityLevel(Enum): 12 | NonBreaking = 'NON_BREAKING' 13 | Dangerous = 'DANGEROUS' 14 | Breaking = 'BREAKING' 15 | 16 | 17 | @dataclass(repr=False) 18 | class Criticality: 19 | level: CriticalityLevel 20 | reason: str 21 | 22 | @classmethod 23 | def breaking(cls, reason): 24 | """Helper constructor of a breaking change""" 25 | return cls(level=CriticalityLevel.Breaking, reason=reason) 26 | 27 | @classmethod 28 | def dangerous(cls, reason): 29 | """Helper constructor of a dangerous change""" 30 | return cls(level=CriticalityLevel.Dangerous, reason=reason) 31 | 32 | @classmethod 33 | def safe(cls, reason="This change won't break any preexisting query"): 34 | """Helper constructor of a safe change""" 35 | return cls(level=CriticalityLevel.NonBreaking, reason=reason) 36 | 37 | def __repr__(self): 38 | # Replace repr because of attrs bug https://github.com/python-attrs/attrs/issues/95 39 | return f"Criticality(level={self.level}, reason={self.reason})" 40 | 41 | 42 | def is_safe_type_change(old_type, new_type) -> bool: 43 | """Depending on the old an new type, a field type change may be breaking, dangerous or safe 44 | 45 | * If both fields are 'leafs' in the sense they don't wrap an inner type, just compare their type. 46 | * If the new type has a non-null constraint, check that it was already non-null and compare against the contained 47 | type. If the contraint is new, just compare with old_type 48 | * If the old type is a list and the new one too, compare their inner type 49 | If the new type has a non-null constraint, compare against the wrapped type 50 | """ 51 | if not is_wrapping_type(old_type) and not is_wrapping_type(new_type): 52 | return str(old_type) == str(new_type) 53 | if is_non_null_type(new_type): 54 | # Grab the inner type it it also was non null. 55 | of_type = old_type.of_type if is_non_null_type(old_type) else old_type 56 | return is_safe_type_change(of_type, new_type.of_type) 57 | 58 | if is_list_type(old_type): 59 | # If both types are lists, compare their inner type. 60 | # If the new type has a non-null constraint, compare with its inner type (may be a list or not) 61 | return ( 62 | ( 63 | is_list_type(new_type) and is_safe_type_change(old_type.of_type, new_type.of_type) 64 | ) 65 | or 66 | ( 67 | is_non_null_type(new_type) and is_safe_type_change(old_type, new_type.of_type) 68 | ) 69 | ) 70 | 71 | return False 72 | 73 | 74 | def is_safe_change_for_input_value(old_type, new_type): 75 | if not is_wrapping_type(old_type) and not is_wrapping_type(new_type): 76 | return str(old_type) == str(new_type) 77 | if is_list_type(old_type) and is_list_type(new_type): 78 | return is_safe_change_for_input_value(old_type.of_type, new_type.of_type) 79 | 80 | if is_non_null_type(old_type): 81 | # Grab the inner type it it also was non null. 82 | of_type = new_type.of_type if is_non_null_type(new_type) else new_type 83 | return is_safe_type_change(old_type.of_type, of_type) 84 | 85 | return False 86 | 87 | 88 | class Change(ABC): 89 | """Common interface of all schema changes 90 | 91 | This class offers the common operations and properties of all 92 | schema changes. You may use it as a type hint to get better 93 | suggestions in your editor of choice. 94 | """ 95 | 96 | criticality: Criticality = None 97 | 98 | restricted: Optional[str] = None 99 | """Descriptive message only present when a change was restricted""" 100 | 101 | @property 102 | def breaking(self) -> bool: 103 | """Is this change a breaking change?""" 104 | return self.criticality.level == CriticalityLevel.Breaking 105 | 106 | @property 107 | def dangerous(self) -> bool: 108 | """Is this a change which might break some naive api clients?""" 109 | return self.criticality.level == CriticalityLevel.Dangerous 110 | 111 | @property 112 | def safe(self) -> bool: 113 | """Is this change safe for all clients?""" 114 | return self.criticality.level == CriticalityLevel.NonBreaking 115 | 116 | @property 117 | @abstractmethod 118 | def message(self) -> str: 119 | """Formatted change message""" 120 | 121 | @property 122 | @abstractmethod 123 | def path(self) -> str: 124 | """Path to the affected schema member""" 125 | 126 | def __repr__(self) -> str: 127 | return f"Change(criticality={self.criticality!r}, message={self.message!r}, path={self.path!r})" 128 | 129 | def __str__(self) -> str: 130 | return self.message 131 | 132 | def to_dict(self) -> dict: 133 | """Get detailed representation of a change""" 134 | return { 135 | 'message': self.message, 136 | 'path': self.path, 137 | 'is_safe_change': self.safe, 138 | 'criticality': { 139 | 'level': self.criticality.level.value, 140 | 'reason': self.criticality.reason 141 | }, 142 | 'checksum': self.checksum(), 143 | } 144 | 145 | def to_json(self) -> str: 146 | """Get detailed representation of a change as a json string""" 147 | return json.dumps(self.to_dict()) 148 | 149 | def checksum(self) -> str: 150 | """Get and identifier of a change. Used for allowlisting changes""" 151 | return hashlib.md5(self.message.encode('utf-8')).hexdigest() 152 | -------------------------------------------------------------------------------- /tests/diff/test_schema.py: -------------------------------------------------------------------------------- 1 | from graphql import build_schema as schema 2 | 3 | from schemadiff.changes import Criticality 4 | from schemadiff.diff.schema import Schema 5 | 6 | 7 | def test_schema_no_diff(): 8 | a_schema = schema(""" 9 | schema { 10 | query: Query 11 | } 12 | type Query { 13 | a: String! 14 | } 15 | """) 16 | diff = Schema(a_schema, a_schema).diff() 17 | assert diff == [] 18 | 19 | 20 | def test_schema_added_type(): 21 | old_schema = schema(""" 22 | schema { 23 | query: Query 24 | } 25 | 26 | type Query { 27 | field: String! 28 | } 29 | """) 30 | new_schema = schema(""" 31 | schema { 32 | query: Query 33 | } 34 | 35 | type Query { 36 | field: String! 37 | } 38 | 39 | type AddedType { 40 | added: Int 41 | } 42 | """) 43 | diff = Schema(old_schema, new_schema).diff() 44 | assert diff and len(diff) == 1 45 | # Type Int was also added but its ignored because its a primitive. 46 | assert diff[0].message == "Type `AddedType` was added" 47 | assert diff[0].criticality == Criticality.safe() 48 | 49 | 50 | def test_schema_removed_type(): 51 | old_schema = schema(""" 52 | schema { 53 | query: Query 54 | } 55 | 56 | type Query { 57 | field: String! 58 | } 59 | 60 | type ToBeRemovedType { 61 | added: Int 62 | } 63 | 64 | """) 65 | new_schema = schema(""" 66 | schema { 67 | query: Query 68 | } 69 | 70 | type Query { 71 | field: String! 72 | } 73 | """) 74 | diff = Schema(old_schema, new_schema).diff() 75 | assert diff and len(diff) == 1 76 | # Type Int was also removed but it is ignored because it's a primitive. 77 | assert diff[0].message == "Type `ToBeRemovedType` was removed" 78 | assert diff[0].criticality == Criticality.breaking( 79 | 'Removing a type is a breaking change. It is preferred to ' 80 | 'deprecate and remove all references to this type first.' 81 | ) 82 | 83 | 84 | def test_schema_query_fields_type_has_changes(): 85 | old_schema = schema(""" 86 | schema { 87 | query: Query 88 | } 89 | 90 | type Query { 91 | field: String! 92 | } 93 | """) 94 | new_schema = schema(""" 95 | schema { 96 | query: Query 97 | } 98 | 99 | type Query { 100 | field: Int! 101 | } 102 | """) 103 | diff = Schema(old_schema, new_schema).diff() 104 | assert diff and len(diff) == 1 105 | assert diff[0].message == "`Query.field` type changed from `String!` to `Int!`" 106 | assert diff[0].criticality == Criticality.breaking( 107 | 'Changing a field type will break queries that assume its type' 108 | ) 109 | 110 | 111 | def test_named_typed_changed_type(): 112 | a = schema(""" 113 | input QueryParams { 114 | a: String! 115 | } 116 | """) 117 | b = schema(""" 118 | type QueryParams { 119 | a: String! 120 | } 121 | """) 122 | diff = Schema(a, b).diff() 123 | assert diff and len(diff) == 1 124 | assert diff[0].message == "`QueryParams` kind changed from `INPUT OBJECT` to `OBJECT`" 125 | assert diff[0].criticality == Criticality.breaking( 126 | 'Changing the kind of a type is a breaking change because ' 127 | 'it can cause existing queries to error. For example, ' 128 | 'turning an object type to a scalar type would break queries ' 129 | 'that define a selection set for this type.' 130 | ) 131 | 132 | 133 | def test_schema_query_root_changed(): 134 | old_schema = schema(""" 135 | schema { 136 | query: Query 137 | } 138 | 139 | type Query { 140 | field: String! 141 | } 142 | """) 143 | new_schema = schema(""" 144 | schema { 145 | query: ChangedQuery 146 | } 147 | 148 | type ChangedQuery { 149 | field: String! 150 | } 151 | """) 152 | diff = Schema(old_schema, new_schema).diff() 153 | print(diff) 154 | assert diff and len(diff) == 3 155 | assert {x.message for x in diff} == { 156 | 'Type `ChangedQuery` was added', 157 | 'Type `Query` was removed', 158 | 'Schema query root has changed from `Query` to `ChangedQuery`' 159 | } 160 | 161 | 162 | def test_schema_mutation_root_changed(): 163 | old_schema = schema(""" 164 | schema { 165 | query: Query 166 | } 167 | 168 | type Query { 169 | field: String! 170 | } 171 | """) 172 | new_schema = schema(""" 173 | schema { 174 | query: Query 175 | mutation: Mutation 176 | } 177 | 178 | type Query { 179 | field: String! 180 | } 181 | 182 | type Mutation { 183 | my_mutation: Int 184 | } 185 | """) 186 | diff = Schema(old_schema, new_schema).diff() 187 | print(diff) 188 | assert diff and len(diff) == 2 189 | assert {x.message for x in diff} == { 190 | 'Type `Mutation` was added', 191 | 'Schema mutation root has changed from `None` to `Mutation`' 192 | } 193 | 194 | 195 | def test_schema_suscription_root_changed(): 196 | old_schema = schema(""" 197 | schema { 198 | query: Query 199 | subscription: Subscription 200 | } 201 | 202 | type Query { 203 | field: String! 204 | } 205 | 206 | type Subscription { 207 | a: Int 208 | } 209 | """) 210 | new_schema = schema(""" 211 | schema { 212 | query: Query 213 | subscription: ChangedSubscription 214 | } 215 | 216 | type Query { 217 | field: String! 218 | } 219 | 220 | type ChangedSubscription { 221 | a: Int 222 | } 223 | 224 | type Subscription { 225 | a: Int 226 | } 227 | """) 228 | diff = Schema(old_schema, new_schema).diff() 229 | print(diff) 230 | assert diff and len(diff) == 2 231 | assert {x.message for x in diff} == { 232 | 'Type `ChangedSubscription` was added', 233 | 'Schema subscription root has changed from `Subscription` to `ChangedSubscription`' 234 | } 235 | -------------------------------------------------------------------------------- /schemadiff/changes/directive.py: -------------------------------------------------------------------------------- 1 | from graphql import is_non_null_type 2 | 3 | from schemadiff.changes import Change, Criticality, is_safe_change_for_input_value 4 | 5 | 6 | class DirectiveChange(Change): 7 | 8 | @property 9 | def path(self): 10 | return f"{self.directive}" 11 | 12 | 13 | class AddedDirective(DirectiveChange): 14 | 15 | criticality = Criticality.safe() 16 | 17 | def __init__(self, directive, directive_locations): 18 | self.directive = directive 19 | self.directive_locations = directive_locations 20 | 21 | @property 22 | def message(self): 23 | locations = ' | '.join(loc.name for loc in self.directive_locations) 24 | return f"Directive `{self.directive}` was added to use on `{locations}`" 25 | 26 | 27 | class RemovedDirective(DirectiveChange): 28 | 29 | criticality = Criticality.breaking("Removing a directive may break clients that depend on them.") 30 | 31 | def __init__(self, directive): 32 | self.directive = directive 33 | 34 | @property 35 | def message(self): 36 | return f"Directive `{self.directive}` was removed" 37 | 38 | 39 | class DirectiveDescriptionChanged(DirectiveChange): 40 | criticality = Criticality.safe() 41 | 42 | def __init__(self, old, new): 43 | self.old = old 44 | self.new = new 45 | 46 | @property 47 | def message(self): 48 | return ( 49 | f"Description for directive `{self.new!s}` " 50 | f"changed from `{self.old.description}` to `{self.new.description}`" 51 | ) 52 | 53 | @property 54 | def path(self): 55 | return f"{self.new}" 56 | 57 | 58 | class DirectiveLocationsChanged(DirectiveChange): 59 | def __init__(self, directive, old_locations, new_locations): 60 | self.directive = directive 61 | self.old_locations = old_locations 62 | self.new_locations = new_locations 63 | self.criticality = ( 64 | Criticality.safe() if self._only_additions() else Criticality.breaking( 65 | "Removing a directive location will break any instance of its usage. " 66 | "Be sure no one uses it before removing it" 67 | ) 68 | ) 69 | 70 | def _only_additions(self): 71 | new_location_names = {l.name for l in self.new_locations} 72 | return all(l.name in new_location_names for l in self.old_locations) 73 | 74 | @property 75 | def message(self): 76 | return ( 77 | f"Directive locations of `{self.directive!s}` changed " 78 | f"from `{' | '.join(l.name for l in self.old_locations)}` " 79 | f"to `{' | '.join(l.name for l in self.new_locations)}`" 80 | ) 81 | 82 | 83 | class DirectiveArgumentAdded(DirectiveChange): 84 | def __init__(self, directive, arg_name, arg_type): 85 | self.criticality = Criticality.safe() if not is_non_null_type(arg_type.type) else Criticality.breaking( 86 | "Adding a non nullable directive argument will break existing usages of the directive" 87 | ) 88 | self.directive = directive 89 | self.arg_name = arg_name 90 | self.arg_type = arg_type 91 | 92 | @property 93 | def message(self): 94 | return f"Added argument `{self.arg_name}: {self.arg_type.type}` to `{self.directive!s}` directive" 95 | 96 | 97 | class DirectiveArgumentRemoved(DirectiveChange): 98 | 99 | criticality = Criticality.breaking("Removing a directive argument will break existing usages of the argument") 100 | 101 | def __init__(self, directive, arg_name, arg_type): 102 | self.directive = directive 103 | self.arg_name = arg_name 104 | self.arg_type = arg_type 105 | 106 | @property 107 | def message(self): 108 | return f"Removed argument `{self.arg_name}: {self.arg_type.type}` from `{self.directive!s}` directive" 109 | 110 | 111 | class DirectiveArgumentTypeChanged(DirectiveChange): 112 | def __init__(self, directive, arg_name, old_type, new_type): 113 | self.criticality = ( 114 | Criticality.breaking("Changing the argument type is a breaking change") 115 | if not is_safe_change_for_input_value(old_type, new_type) 116 | else Criticality.safe("Changing an input field from non-null to null is considered non-breaking") 117 | ) 118 | self.directive = directive 119 | self.arg_name = arg_name 120 | self.old_type = old_type 121 | self.new_type = new_type 122 | 123 | @property 124 | def message(self): 125 | return ( 126 | f"Type for argument `{self.arg_name}` on `{self.directive!s}` directive changed " 127 | f"from `{self.old_type}` to `{self.new_type}`" 128 | ) 129 | 130 | 131 | class DirectiveArgumentDefaultChanged(DirectiveChange): 132 | def __init__(self, directive, arg_name, old_default, new_default): 133 | self.criticality = Criticality.dangerous( 134 | "Changing the default value for an argument may change the runtime " 135 | "behaviour of a field if it was never provided." 136 | ) 137 | self.directive = directive 138 | self.arg_name = arg_name 139 | self.old_default = old_default 140 | self.new_default = new_default 141 | 142 | @property 143 | def message(self): 144 | return ( 145 | f"Default value for argument `{self.arg_name}` on `{self.directive!s}` directive changed " 146 | f"from `{self.old_default!r}` to `{self.new_default!r}`" 147 | ) 148 | 149 | 150 | class DirectiveArgumentDescriptionChanged(DirectiveChange): 151 | criticality = Criticality.safe() 152 | 153 | def __init__(self, directive, arg_name, old_desc, new_desc): 154 | self.directive = directive 155 | self.arg_name = arg_name 156 | self.old_desc = old_desc 157 | self.new_desc = new_desc 158 | 159 | @property 160 | def message(self): 161 | return ( 162 | f"Description for argument `{self.arg_name}` on `{self.directive!s}` directive changed " 163 | f"from `{self.old_desc}` to `{self.new_desc}`" 164 | ) 165 | -------------------------------------------------------------------------------- /schemadiff/diff/schema.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | from graphql import ( 4 | is_enum_type, 5 | is_union_type, 6 | is_input_object_type, 7 | is_object_type, 8 | is_interface_type, 9 | ) 10 | 11 | from schemadiff.changes.directive import RemovedDirective, AddedDirective 12 | from schemadiff.changes.schema import ( 13 | SchemaQueryTypeChanged, 14 | SchemaMutationTypeChanged, 15 | SchemaSubscriptionTypeChanged, 16 | ) 17 | from schemadiff.changes.type import ( 18 | AddedType, 19 | RemovedType, 20 | TypeDescriptionChanged, 21 | TypeKindChanged, 22 | ) 23 | from schemadiff.diff.directive import Directive 24 | from schemadiff.diff.enum import EnumDiff 25 | from schemadiff.diff.interface import InterfaceType 26 | from schemadiff.diff.object_type import ObjectType 27 | from schemadiff.diff.union_type import UnionType 28 | from schemadiff.diff.input_object_type import InputObjectType 29 | 30 | 31 | def _get_type_resolvers(): 32 | """Get TypeResolvers/TypeFields with fallback for different graphql-core versions.""" 33 | # Try modern graphql-core (>= 3.2.7) - TypeFields is the official replacement 34 | try: 35 | from graphql.type.introspection import TypeFields 36 | return TypeFields 37 | except ImportError: 38 | pass 39 | 40 | # Try graphql-core 3.2.0 - 3.2.6 41 | try: 42 | from graphql.type.introspection import TypeResolvers 43 | return TypeResolvers 44 | except ImportError: 45 | pass 46 | 47 | # Fallback for older graphql-core (< 3.2.0) 48 | try: 49 | from graphql.type.introspection import TypeFieldResolvers 50 | return TypeFieldResolvers 51 | except ImportError: 52 | raise ImportError( 53 | "Could not import TypeFields, TypeResolvers, or TypeFieldResolvers from graphql-core. " 54 | "Please upgrade to a supported version of graphql-core." 55 | ) 56 | 57 | 58 | TypeResolvers = _get_type_resolvers() 59 | 60 | type_kind = partial(TypeResolvers.kind, _info={}) 61 | 62 | 63 | class Schema: 64 | primitives = {'String', 'Int', 'Float', 'Boolean', 'ID'} 65 | internal_types = {'__Schema', '__Type', '__TypeKind', '__Field', '__InputValue', '__EnumValue', 66 | '__Directive', '__DirectiveLocation'} 67 | 68 | def __init__(self, old_schema, new_schema): 69 | self.old_schema = old_schema 70 | self.new_schema = new_schema 71 | 72 | self.old_types = old_schema.type_map 73 | self.new_types = new_schema.type_map 74 | 75 | self.old_directives = old_schema.directives 76 | self.new_directives = new_schema.directives 77 | 78 | def diff(self): 79 | changes = [] 80 | changes += self.type_changes() 81 | changes += self.directive_changes() 82 | changes += self.schema_changes() 83 | return changes 84 | 85 | def schema_changes(self): 86 | changes = [] 87 | old, new = self.old_schema, self.new_schema 88 | if str(old.query_type) != str(new.query_type): 89 | changes.append(SchemaQueryTypeChanged(str(old.query_type), str(new.query_type))) 90 | if str(old.mutation_type) != str(new.mutation_type): 91 | changes.append(SchemaMutationTypeChanged(str(old.mutation_type), str(new.mutation_type))) 92 | if str(old.subscription_type) != str(new.subscription_type): 93 | changes.append(SchemaSubscriptionTypeChanged(str(old.subscription_type), str(new.subscription_type))) 94 | 95 | return changes 96 | 97 | def type_changes(self): 98 | changes = [] 99 | changes += self.removed_types() 100 | changes += self.added_types() 101 | changes += self.common_type_changes() 102 | return changes 103 | 104 | def removed_types(self): 105 | return [ 106 | RemovedType(self.old_types[field_name]) 107 | for field_name in self.old_types 108 | if field_name not in self.new_types and not self.is_primitive(field_name) 109 | ] 110 | 111 | def added_types(self): 112 | return [ 113 | AddedType(self.new_types[field_name]) 114 | for field_name in self.new_types 115 | if field_name not in self.old_types and not self.is_primitive(field_name) 116 | ] 117 | 118 | def common_type_changes(self): 119 | changes = [] 120 | 121 | common_types = (set(self.old_types.keys()) & set(self.new_types.keys())) - self.primitives - self.internal_types 122 | for type_name in common_types: 123 | old_type = self.old_types[type_name] 124 | new_type = self.new_types[type_name] 125 | changes += self.compare_types(old_type, new_type) 126 | 127 | return changes 128 | 129 | @staticmethod 130 | def compare_types(old_type, new_type): 131 | changes = [] 132 | if old_type.description != new_type.description: 133 | changes.append(TypeDescriptionChanged(new_type.name, old_type.description, new_type.description)) 134 | 135 | if type_kind(old_type) != type_kind(new_type): 136 | changes.append(TypeKindChanged(new_type, type_kind(old_type), type_kind(new_type))) 137 | else: 138 | if is_enum_type(old_type): 139 | changes += EnumDiff(old_type, new_type).diff() 140 | elif is_union_type(old_type): 141 | changes += UnionType(old_type, new_type).diff() 142 | elif is_input_object_type(old_type): 143 | changes += InputObjectType(old_type, new_type).diff() 144 | elif is_object_type(old_type): 145 | changes += ObjectType(old_type, new_type).diff() 146 | elif is_interface_type(old_type): 147 | changes += InterfaceType(old_type, new_type).diff() 148 | 149 | return changes 150 | 151 | def directive_changes(self): 152 | changes = [] 153 | changes += self.removed_directives() 154 | changes += self.added_directives() 155 | changes += self.common_directives_changes() 156 | return changes 157 | 158 | def removed_directives(self): 159 | new_directive_names = {x.name for x in self.new_directives} 160 | return [ 161 | RemovedDirective(directive) 162 | for directive in self.old_directives 163 | if directive.name not in new_directive_names 164 | ] 165 | 166 | def added_directives(self): 167 | old_directive_names = {x.name for x in self.old_directives} 168 | return [ 169 | AddedDirective(directive, directive.locations) 170 | for directive in self.new_directives 171 | if directive.name not in old_directive_names 172 | ] 173 | 174 | def common_directives_changes(self): 175 | old_directive_names = {x.name for x in self.old_directives} 176 | new_directive_names = {x.name for x in self.new_directives} 177 | old_directives = { 178 | x.name: x 179 | for x in self.old_directives 180 | } 181 | new_directives = { 182 | x.name: x 183 | for x in self.new_directives 184 | } 185 | 186 | changes = [] 187 | for directive_name in old_directive_names & new_directive_names: 188 | changes += Directive(old_directives[directive_name], new_directives[directive_name]).diff() 189 | 190 | return changes 191 | 192 | def is_primitive(self, atype): 193 | return atype in self.primitives 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

5 | 6 | Build status 7 | 8 | 9 | codecov 10 | 11 | 12 | Codacy 13 | 14 | 15 | License: GPL v3 16 | 17 | 18 | Python 3.6+ 19 | 20 |

21 |

22 | 23 |

24 | 25 | # schemadiff 26 | `schemadiff` is a lib that shows you the difference between two GraphQL Schemas. 27 | It takes two schemas from a string or a file and gives you a list of changes between both versions. 28 | This might be useful for: 29 | * Detecting breaking changes before they reach the api clients 30 | * Integrating into CI pipelines to control your api evolution 31 | * Document your api changes and submit them for an approval along with your pull requests. 32 | * Restricting unwanted changes 33 | 34 | ## Installation 35 | The lib requires python3.6 or greater to work. In order to install it run 36 | ```bash 37 | $ python3 -m pip install graphql-schema-diff 38 | ``` 39 | 40 | ## Usage 41 | You can use this package as a lib or as a CLI. You can choose what better suits your needs 42 | 43 | ### As a Lib 44 | ```python 45 | from schemadiff import diff, diff_from_file, print_diff 46 | 47 | old_schema = """ 48 | schema { 49 | query: Query 50 | } 51 | 52 | type Query { 53 | a: Int!, 54 | sum(start: Float=0): Int 55 | } 56 | """ 57 | 58 | new_schema = """ 59 | schema { 60 | query: Query 61 | } 62 | 63 | type Query { 64 | b: String, 65 | sum(start: Float=1): Int 66 | } 67 | """ 68 | 69 | changes = diff(old_schema, new_schema) 70 | print_diff(changes) # Pretty print difference 71 | any(change.breaking or change.dangerous for change in changes) # Check if there was any breaking or dangerous change 72 | 73 | # You can also compare from schema files 74 | with open('old_schema.gql', 'w') as f: 75 | f.write(old_schema) 76 | 77 | with open('new_schema.gql', 'w') as f: 78 | f.write(new_schema) 79 | 80 | changes = diff_from_file('old_schema.gql', 'new_schema.gql') 81 | print_diff(changes) 82 | ``` 83 | ### CLI 84 | Inside your virtualenv you can invoke the entrypoint to see its usage options 85 | ```bash 86 | $ schemadiff -h 87 | Usage: schemadiff [-h] -o OLD_SCHEMA -n NEW_SCHEMA [-j] [-a ALLOW_LIST] [-t] [-r] [-s] 88 | 89 | Schema comparator 90 | 91 | optional arguments: 92 | -h, --help show this help message and exit 93 | -o OLD_SCHEMA, --old-schema OLD_SCHEMA 94 | Path to old graphql schema file 95 | -n NEW_SCHEMA, --new-schema NEW_SCHEMA 96 | Path to new graphql schema file 97 | -j, --as-json Output a detailed summary of changes in json format 98 | -a ALLOW_LIST, --allow-list ALLOW_LIST 99 | Path to the allowed list of changes 100 | -t, --tolerant Tolerant mode. Error out only if there's a breaking 101 | change but allow dangerous changes 102 | -r, --restrictions Restricted mode. Error out on restricted changes. 103 | -s, --strict Strict mode. Error out on dangerous and breaking 104 | changes. 105 | ``` 106 | #### Examples 107 | ```bash 108 | # Compare schemas and output diff to stdout 109 | schemadiff -o tests/data/simple_schema.gql -n tests/data/new_schema.gql 110 | 111 | # Pass a evaluation flag (mixing long arg name and short arg name) 112 | schemadiff --old-schema tests/data/simple_schema.gql -n tests/data/new_schema.gql --strict 113 | 114 | # Print output as json with details of each change 115 | schemadiff -o tests/data/simple_schema.gql -n tests/data/new_schema.gql --as-json 116 | 117 | # Save output to a json file 118 | schemadiff -o tests/data/simple_schema.gql -n tests/data/new_schema.gql --as-json > changes.json 119 | 120 | # Compare schemas ignoring allowed changes 121 | schemadiff -o tests/data/simple_schema.gql -n tests/data/new_schema.gql -a allowlist.json 122 | 123 | # Compare schemas restricting adding new types without description 124 | schemadiff -o tests/data/simple_schema.gql -n simple_schema_new_type_without_description.gql -r add-type-without-description 125 | ``` 126 | 127 | >If you run the cli and see a replacement character (�) or a square box (□) instead of the emojis run 128 | >```bash 129 | >$ sudo apt install fonts-noto-color-emoji 130 | >$ vim ~/.config/fontconfig/fonts.conf # and paste https://gist.github.com/Ambro17/80bce76d07a6eb74323db2ca9b887263 131 | >$ fc-cache -f -v 132 | >``` 133 | >That should install noto emoji fonts and set is as the fallback font to render emojis 😎 134 | 135 | ## Restricting changes 136 | You can use this library to validate whether your schema matches a set of rules. 137 | 138 | ### Built-in restrictions 139 | The library has its own built-in restrictions ready-to-use. Just append them to the `-r` command in `CLI`. You can 140 | add as many as you want. 141 | 142 | - `add-type-without-description` 143 | Restrict adding new GraphQL types without entering a non-empty description. 144 | 145 | - `remove-type-description` 146 | Restrict removing the description from an existing GraphQL type. 147 | 148 | - `add-field-without-description` 149 | Restrict adding fields without description. 150 | 151 | - `remove-field-description` 152 | Restrict removing the description from an existing GraphQL field. 153 | 154 | - `add-enum-value-without-description` 155 | Restrict adding enum value without description. 156 | 157 | - `remove-enum-value-description` 158 | Restrict adding enum value without description. 159 | 160 | Running the following command, you could restrict type additions without entering a nice description. 161 | ``` 162 | # Compare schemas restricting adding new types without description 163 | schemadiff -o tests/data/simple_schema.gql -n simple_schema_new_type_without_description.gql -r add-type-without-description 164 | ``` 165 | 166 | 167 | ## API Reference 168 | You can also read the [API Reference](https://ambro17.github.io/graphql-schema-diff/) if you want to get a better understanding of the inner workings of the lib 169 | 170 | ## Credits 171 | Implementation was heavily inspired by Marc Giroux [ruby version](https://github.com/xuorig/graphql-schema_comparator) 172 | and Kamil Kisiela [js implementation](https://github.com/kamilkisiela/graphql-inspector). 173 | 174 | Logo arrows were adapted from the work of [Paul Verhulst @ The Noun Project](https://thenounproject.com/paulverhulst/) 175 | 176 | ## Contributors 177 | - @Checho3388: Added the whole `evaluate_diff` functionality. Thank you! 178 | 179 | You can contribute reporting bugs, writing issues or pull requests for any new features! -------------------------------------------------------------------------------- /tests/diff/test_input_object.py: -------------------------------------------------------------------------------- 1 | from graphql import build_schema as schema 2 | 3 | from schemadiff.changes import Criticality 4 | from schemadiff.diff.schema import Schema 5 | 6 | ERROR = 'Changing the type of an input field can break existing queries that use this field' 7 | 8 | 9 | def test_input_field_no_diff(): 10 | a = schema(""" 11 | input Params { 12 | will: String! 13 | love: Int! 14 | } 15 | """) 16 | b = schema(""" 17 | input Params { 18 | will: String! 19 | love: Int! 20 | } 21 | """) 22 | diff = Schema(a, b).diff() 23 | assert not diff 24 | 25 | 26 | def test_input_field_type_changed(): 27 | a = schema(""" 28 | input Params { 29 | will: String! 30 | love: Int 31 | } 32 | """) 33 | b = schema(""" 34 | input Params { 35 | will: String! 36 | love: Float! 37 | } 38 | """) 39 | diff = Schema(a, b).diff() 40 | assert diff and len(diff) == 1 41 | assert diff[0].message == "`Params.love` type changed from `Int` to `Float!`" 42 | assert diff[0].path == 'Params.love' 43 | assert diff[0].criticality == Criticality.breaking( 44 | 'Changing the type of an input field can break existing queries that use this field' 45 | ) 46 | 47 | 48 | def test_input_field_changed_from_list_to_scalar(): 49 | a = schema(""" 50 | input Params { 51 | arg: Int 52 | } 53 | """) 54 | b = schema(""" 55 | input Params { 56 | arg: [Int] 57 | } 58 | """) 59 | diff = Schema(a, b).diff() 60 | assert diff and len(diff) == 1 61 | assert diff[0].message == "`Params.arg` type changed from `Int` to `[Int]`" 62 | assert diff[0].path == 'Params.arg' 63 | assert diff[0].criticality == Criticality.breaking( 64 | 'Changing the type of an input field can break existing queries that use this field' 65 | ) 66 | 67 | 68 | def test_input_field_dropped_non_null_constraint(): 69 | a = schema(""" 70 | input Params { 71 | arg: String! 72 | } 73 | """) 74 | b = schema(""" 75 | input Params { 76 | arg: String 77 | } 78 | """) 79 | diff = Schema(a, b).diff() 80 | assert diff and len(diff) == 1 81 | assert diff[0].message == "`Params.arg` type changed from `String!` to `String`" 82 | assert diff[0].path == 'Params.arg' 83 | assert diff[0].criticality == Criticality.safe() 84 | 85 | 86 | def test_input_field_now_is_not_nullable(): 87 | a = schema(""" 88 | input Params { 89 | arg: ID 90 | } 91 | """) 92 | b = schema(""" 93 | input Params { 94 | arg: ID! 95 | } 96 | """) 97 | diff = Schema(a, b).diff() 98 | assert diff and len(diff) == 1 99 | assert diff[0].message == "`Params.arg` type changed from `ID` to `ID!`" 100 | assert diff[0].path == 'Params.arg' 101 | assert diff[0].criticality == Criticality.breaking(ERROR) 102 | 103 | 104 | def test_input_field_type_nullability_change_on_lists_of_the_same_underlying_types(): 105 | a = schema(""" 106 | input Params { 107 | arg: [ID!]! 108 | } 109 | """) 110 | b = schema(""" 111 | input Params { 112 | arg: [ID!] 113 | } 114 | """) 115 | c = schema(""" 116 | input Params { 117 | arg: [ID] 118 | } 119 | """) 120 | d = schema(""" 121 | input Params { 122 | arg: ID 123 | } 124 | """) 125 | diff = Schema(a, b).diff() 126 | assert diff and len(diff) == 1 127 | assert diff[0].message == "`Params.arg` type changed from `[ID!]!` to `[ID!]`" 128 | assert diff[0].path == 'Params.arg' 129 | assert diff[0].criticality == Criticality.safe() # Because dropping the non-null constraint will not break anything 130 | 131 | diff = Schema(a, c).diff() 132 | assert diff[0].message == "`Params.arg` type changed from `[ID!]!` to `[ID]`" 133 | assert diff[0].path == 'Params.arg' 134 | assert diff[0].criticality == Criticality.breaking(ERROR) 135 | 136 | diff = Schema(a, d).diff() 137 | assert diff[0].message == "`Params.arg` type changed from `[ID!]!` to `ID`" 138 | assert diff[0].path == 'Params.arg' 139 | assert diff[0].criticality == Criticality.breaking(ERROR) 140 | 141 | 142 | def test_input_field_inner_type_changed(): 143 | a = schema(""" 144 | input Params { 145 | arg: [Int] 146 | } 147 | """) 148 | b = schema(""" 149 | input Params { 150 | arg: [String] 151 | } 152 | """) 153 | diff = Schema(a, b).diff() 154 | assert diff and len(diff) == 1 155 | assert diff[0].message == "`Params.arg` type changed from `[Int]` to `[String]`" 156 | assert diff[0].path == 'Params.arg' 157 | assert diff[0].criticality == Criticality.breaking(ERROR) 158 | 159 | 160 | def test_input_field_default_value_changed(): 161 | a = schema(""" 162 | input Params { 163 | love: Int = 0 164 | } 165 | """) 166 | b = schema(""" 167 | input Params { 168 | love: Int = 100 169 | } 170 | """) 171 | diff = Schema(a, b).diff() 172 | assert diff and len(diff) == 1 173 | assert diff[0].message == "Default value for input field `Params.love` changed from `0` to `100`" 174 | assert diff[0].path == 'Params.love' 175 | assert diff[0].criticality == Criticality.dangerous( 176 | 'Changing the default value for an argument may change ' 177 | 'the runtime behaviour of a field if it was never provided.' 178 | ) 179 | 180 | 181 | def test_input_field_description_changed(): 182 | a = schema(''' 183 | input Params { 184 | """abc""" 185 | love: Int 186 | } 187 | ''') 188 | b = schema(''' 189 | input Params { 190 | """His description""" 191 | love: Int 192 | } 193 | ''') 194 | diff = Schema(a, b).diff() 195 | assert diff and len(diff) == 1 196 | assert diff[0].message == ( 197 | "Description for Input field `Params.love` changed from `abc` to `His description`" 198 | ) 199 | assert diff[0].path == 'Params.love' 200 | assert diff[0].criticality == Criticality.safe() 201 | 202 | 203 | def test_input_field_added_field(): 204 | a = schema(""" 205 | input Recipe { 206 | ingredients: [String] 207 | } 208 | """) 209 | b = schema(""" 210 | input Recipe { 211 | ingredients: [String] 212 | love: Float 213 | } 214 | """) 215 | diff = Schema(a, b).diff() 216 | assert diff and len(diff) == 1 217 | assert diff[0].message == ( 218 | "Input Field `love: Float` was added to input type `Recipe`" 219 | ) 220 | assert diff[0].path == 'Recipe.love' 221 | assert diff[0].criticality == Criticality.safe() 222 | 223 | diff = Schema(b, a).diff() 224 | assert diff and len(diff) == 1 225 | assert diff[0].message == ( 226 | "Input Field `love` removed from input type `Recipe`" 227 | ) 228 | assert diff[0].path == 'Recipe.love' 229 | assert diff[0].criticality == Criticality.breaking( 230 | 'Removing an input field will break queries that use this input field.' 231 | ) 232 | 233 | 234 | def test_add_non_null_input_field(): 235 | a = schema(""" 236 | input Recipe { 237 | ingredients: [String] 238 | } 239 | """) 240 | b = schema(""" 241 | input Recipe { 242 | ingredients: [String] 243 | love: Float! 244 | } 245 | """) 246 | diff = Schema(a, b).diff() 247 | assert diff and len(diff) == 1 248 | assert diff[0].message == ( 249 | "Input Field `love: Float!` was added to input type `Recipe`" 250 | ) 251 | assert diff[0].path == 'Recipe.love' 252 | assert diff[0].criticality == Criticality.breaking( 253 | 'Adding a non-null field to an existing input type will cause existing ' 254 | 'queries that use this input type to break because they will not provide a value for this new field.' 255 | ) 256 | -------------------------------------------------------------------------------- /tests/diff/test_field.py: -------------------------------------------------------------------------------- 1 | from graphql import build_schema as schema 2 | 3 | from schemadiff.changes import Criticality 4 | from schemadiff.diff.schema import Schema 5 | 6 | 7 | def test_no_field_diff(): 8 | a = schema(""" 9 | type Query { 10 | b: Int! 11 | } 12 | """) 13 | b = schema(""" 14 | type Query { 15 | b: Int! 16 | } 17 | """) 18 | diff = Schema(a, b).diff() 19 | assert not diff 20 | 21 | 22 | def test_field_type_changed(): 23 | a_schema = schema(""" 24 | type Query { 25 | a: String! 26 | } 27 | """) 28 | changed_schema = schema(""" 29 | type Query { 30 | a: Int 31 | } 32 | """) 33 | diff = Schema(a_schema, changed_schema).diff() 34 | assert len(diff) == 1 35 | assert diff[0].message == "`Query.a` type changed from `String!` to `Int`" 36 | assert diff[0].path == 'Query.a' 37 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type') 38 | 39 | 40 | def test_field_type_change_from_scalar_to_list_of_the_same_type(): 41 | a = schema(""" 42 | type Query { 43 | a: String 44 | } 45 | """) 46 | b = schema(""" 47 | type Query { 48 | a: [String] 49 | } 50 | """) 51 | diff = Schema(a, b).diff() 52 | assert len(diff) == 1 53 | assert diff[0].message == "`Query.a` type changed from `String` to `[String]`" 54 | assert diff[0].path == 'Query.a' 55 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type') 56 | 57 | 58 | def test_field_nullability_changed(): 59 | a = schema(""" 60 | type Query { 61 | a: Int! 62 | } 63 | """) 64 | b = schema(""" 65 | type Query { 66 | a: Int 67 | } 68 | """) 69 | diff = Schema(a, b).diff() 70 | assert len(diff) == 1 71 | assert diff[0].message == "`Query.a` type changed from `Int!` to `Int`" 72 | assert diff[0].path == 'Query.a' 73 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type') 74 | 75 | diff = Schema(b, a).diff() 76 | assert len(diff) == 1 77 | assert diff[0].message == "`Query.a` type changed from `Int` to `Int!`" 78 | assert diff[0].path == 'Query.a' 79 | assert diff[0].criticality == Criticality.safe() 80 | 81 | 82 | def test_field_type_change_nullability_change_on_lists_of_same_type(): 83 | a = schema(""" 84 | type Query { 85 | a: [Boolean]! 86 | } 87 | """) 88 | b = schema(""" 89 | type Query { 90 | a: [Boolean] 91 | } 92 | """) 93 | diff = Schema(a, b).diff() 94 | assert len(diff) == 1 95 | assert diff[0].message == "`Query.a` type changed from `[Boolean]!` to `[Boolean]`" 96 | assert diff[0].path == 'Query.a' 97 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type') 98 | 99 | 100 | def test_field_listof_nullability_of_inner_type_changed(): 101 | a = schema(""" 102 | type Query { 103 | a: [Int!]! 104 | } 105 | """) 106 | b = schema(""" 107 | type Query { 108 | a: [Int]! 109 | } 110 | """) 111 | diff = Schema(a, b).diff() 112 | assert len(diff) == 1 113 | assert diff[0].message == "`Query.a` type changed from `[Int!]!` to `[Int]!`" 114 | assert diff[0].path == 'Query.a' 115 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type') 116 | 117 | 118 | def test_field_listof_nullability_of_inner_and_outer_types_changed(): 119 | a = schema(""" 120 | type Query { 121 | a: [Float!]! 122 | } 123 | """) 124 | b = schema(""" 125 | type Query { 126 | a: [Float] 127 | } 128 | """) 129 | diff = Schema(a, b).diff() 130 | assert len(diff) == 1 131 | assert diff[0].message == "`Query.a` type changed from `[Float!]!` to `[Float]`" 132 | assert diff[0].path == 'Query.a' 133 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type') 134 | 135 | 136 | def test_field_list_of_inner_type_changed(): 137 | a = schema(""" 138 | type Query { 139 | a: [Float!]! 140 | } 141 | """) 142 | b = schema(""" 143 | type Query { 144 | a: [String] 145 | } 146 | """) 147 | diff = Schema(a, b).diff() 148 | assert len(diff) == 1 149 | assert diff[0].message == "`Query.a` type changed from `[Float!]!` to `[String]`" 150 | assert diff[0].path == 'Query.a' 151 | assert diff[0].criticality == Criticality.breaking('Changing a field type will break queries that assume its type') 152 | 153 | 154 | def test_description_changed(): 155 | a = schema(''' 156 | type Query { 157 | """some desc""" 158 | a: String 159 | } 160 | ''') 161 | b = schema(''' 162 | type Query { 163 | """once upon a time""" 164 | a: String 165 | } 166 | ''') 167 | diff = Schema(a, b).diff() 168 | assert len(diff) == 1 169 | assert diff[0].message == "`Query.a` description changed from `some desc` to `once upon a time`" 170 | assert diff[0].path == 'Query.a' 171 | assert diff[0].criticality == Criticality.safe() 172 | 173 | 174 | def test_deprecation_reason_changed(): 175 | a = schema(''' 176 | type Query { 177 | b: Int! @deprecated(reason: "Not used") 178 | } 179 | ''') 180 | b = schema(''' 181 | type Query { 182 | b: Int! @deprecated(reason: "Some string") 183 | } 184 | ''') 185 | diff = Schema(a, b).diff() 186 | assert diff and len(diff) == 1 187 | assert diff[0].message == "Deprecation reason on field `Query.b` changed from `Not used` to `Some string`" 188 | assert diff[0].path == 'Query.b' 189 | assert diff[0].criticality == Criticality.safe() 190 | 191 | 192 | def test_added_removed_arguments(): 193 | a = schema(""" 194 | type Football { 195 | skill: Float! 196 | } 197 | """) 198 | b = schema(""" 199 | type Football { 200 | skill(player: ID): Float! 201 | } 202 | """) 203 | diff = Schema(a, b).diff() 204 | assert diff and len(diff) == 1 205 | assert diff[0].message == "Argument `player: ID` added to `Football.skill`" 206 | assert diff[0].path == 'Football.skill' 207 | assert diff[0].criticality == Criticality.safe('Adding an optional argument is a safe change') 208 | 209 | diff = Schema(b, a).diff() 210 | assert diff and len(diff) == 1 211 | assert diff[0].message == "Removed argument `player` from `Football.skill`" 212 | assert diff[0].path == 'Football.skill' 213 | assert diff[0].criticality == Criticality.breaking( 214 | 'Removing a field argument will break queries that use this argument' 215 | ) 216 | 217 | c = schema(""" 218 | type Football { 219 | skill(player: ID, age: Int): Float! 220 | } 221 | """) 222 | diff = Schema(b, c).diff() 223 | assert diff and len(diff) == 1 224 | assert diff[0].message == "Argument `age: Int` added to `Football.skill`" 225 | assert diff[0].path == 'Football.skill' 226 | assert diff[0].criticality == Criticality.safe('Adding an optional argument is a safe change') 227 | 228 | 229 | def test_field_type_change_is_safe(): 230 | non_nullable_schema = schema(""" 231 | type Query { 232 | a: Int! 233 | } 234 | """) 235 | nullable_schema = schema(""" 236 | type Query { 237 | a: Int 238 | } 239 | """) 240 | # Dropping the non-null constraint is a breaking change because clients may have assumed it could never be null 241 | diff = Schema(non_nullable_schema, nullable_schema).diff() 242 | assert len(diff) == 1 243 | assert diff[0].criticality == Criticality.breaking( 244 | 'Changing a field type will break queries that assume its type' 245 | ) 246 | 247 | # But if it was already nullable, they already had to handle that case. 248 | diff = Schema(nullable_schema, non_nullable_schema).diff() 249 | assert len(diff) == 1 250 | assert diff[0].criticality == Criticality.safe() 251 | -------------------------------------------------------------------------------- /images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 21 | 23 | 24 | 26 | image/svg+xml 27 | 29 | 30 | 31 | 32 | 33 | 35 | 63 | 67 | 71 | 72 | 82 | 92 | 99 | 104 | 109 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | from operator import itemgetter 4 | from unittest.mock import patch 5 | 6 | import pytest 7 | 8 | from schemadiff.__main__ import main, parse_args, cli 9 | 10 | 11 | def test_no_diff(capsys): 12 | SCHEMA_FILE = 'tests/data/simple_schema.gql' 13 | args = parse_args([ 14 | '-o', SCHEMA_FILE, 15 | '-n', SCHEMA_FILE, 16 | ]) 17 | exit_code = main(args) 18 | stdout = capsys.readouterr() 19 | assert exit_code == 0 20 | assert stdout.out == '🎉 Both schemas are equal!\n' 21 | assert stdout.err == '' 22 | 23 | 24 | def test_schema_path_not_found(capsys): 25 | SCHEMA_FILE = 'tests/data/not_a_path.gql' 26 | with pytest.raises(SystemExit): 27 | parse_args([ 28 | '-o', SCHEMA_FILE, 29 | '-n', SCHEMA_FILE, 30 | ]) 31 | assert "No such file or directory: 'tests/data/not_a_path.gql'" in capsys.readouterr().err 32 | 33 | 34 | def test_schema_default_mode(capsys): 35 | SCHEMA_FILE = 'tests/data/simple_schema.gql' 36 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_dangerous_changes.gql' 37 | args = parse_args([ 38 | '--old-schema', SCHEMA_FILE, 39 | '--new-schema', ANOTHER_SCHEMA_FILE, 40 | ]) 41 | exit_code = main(args) 42 | assert exit_code == 0 43 | 44 | stdout = capsys.readouterr() 45 | assert "✔️ Field `c` was added to object type `Query`" in stdout.out 46 | assert "⚠️ Default value for argument `x` on field `Field.calculus` changed from `0` to `100`" in stdout.out 47 | 48 | 49 | def test_schema_default_mode_json_output(capsys): 50 | SCHEMA_FILE = 'tests/data/simple_schema.gql' 51 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_dangerous_changes.gql' 52 | args = parse_args([ 53 | '--old-schema', SCHEMA_FILE, 54 | '--new-schema', ANOTHER_SCHEMA_FILE, 55 | '--as-json' 56 | ]) 57 | exit_code = main(args) 58 | assert exit_code == 0 59 | 60 | stdout = capsys.readouterr().out 61 | result = json.loads(stdout) 62 | assert sorted(result, key=itemgetter('path')) == sorted([ 63 | { 64 | 'criticality': { 65 | 'level': 'NON_BREAKING', 66 | 'reason': "This change won't break any preexisting query" 67 | }, 68 | 'is_safe_change': True, 69 | 'message': 'Field `c` was added to object type `Query`', 70 | 'path': 'Query.c', 71 | 'checksum': '1e3b776bda2dd8b11804e7341bb8b2d1', 72 | }, 73 | { 74 | 'criticality': { 75 | 'level': 'DANGEROUS', 76 | 'reason': 'Changing the default value for an argument ' 77 | 'may change the runtime behaviour of a field if it was never provided.' 78 | }, 79 | 'is_safe_change': False, 80 | 'message': 'Default value for argument `x` on field `Field.calculus` changed from `0` to `100`', 81 | 'path': 'Field.calculus', 82 | 'checksum': 'a43d73d21c69cbd72334c06904439f50', 83 | } 84 | ], key=itemgetter('path')) 85 | 86 | 87 | def test_cli_with_allow_list_does_not_show_allowed_changes(capsys): 88 | SCHEMA_FILE = 'tests/data/simple_schema.gql' 89 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_breaking_changes.gql' 90 | ALLOW_LIST = 'tests/data/allowlist.json' 91 | args = parse_args([ 92 | '--old-schema', SCHEMA_FILE, 93 | '--new-schema', ANOTHER_SCHEMA_FILE, 94 | '-a', ALLOW_LIST 95 | ]) 96 | exit_code = main(args) 97 | assert exit_code == 0 98 | 99 | stdout = capsys.readouterr().out 100 | # The only difference between both schemas was allowed, so there are no differences. 101 | assert stdout == '🎉 Both schemas are equal!\n' 102 | 103 | 104 | def test_schema_strict_mode(capsys): 105 | SCHEMA_FILE = 'tests/data/simple_schema.gql' 106 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_dangerous_changes.gql' 107 | args = parse_args([ 108 | '-o', SCHEMA_FILE, 109 | '--new-schema', ANOTHER_SCHEMA_FILE, 110 | '--strict' 111 | ]) 112 | exit_code = main(args) 113 | # As we run the comparison in strict mode and there is a dangerous change, the exit code is 1 114 | assert exit_code == 1 115 | 116 | stdout = capsys.readouterr() 117 | assert "✔️ Field `c` was added to object type `Query`" in stdout.out 118 | assert "⚠️ Default value for argument `x` on field `Field.calculus` changed from `0` to `100`" in stdout.out 119 | 120 | 121 | def test_schema_rules_mode(capsys): 122 | SCHEMA_FILE = 'tests/data/simple_schema_rules_validation.gql' 123 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_rules_validation_new.gql' 124 | RULES = [ 125 | 'add-type-without-description', 126 | 'remove-type-description', 127 | 'add-field-without-description', 128 | 'remove-field-description', 129 | 'add-enum-value-without-description', 130 | 'remove-enum-value-description', 131 | ] 132 | args = parse_args([ 133 | '-o', SCHEMA_FILE, 134 | '--new-schema', ANOTHER_SCHEMA_FILE, 135 | '--validation-rules', *RULES 136 | ]) 137 | exit_code = main(args) 138 | # As we run the comparison in validation mode and there is a restricted change, the exit code is 1 139 | assert exit_code == 3 140 | 141 | stdout = capsys.readouterr() 142 | 143 | assert "⛔ Type `NewTypeWithoutDesc` was added without a description for NewTypeWithoutDesc " \ 144 | "(rule: `add-type-without-description`)" in stdout.out 145 | assert "⛔ Type `NewEnumWithoutDesc` was added without a description for NewEnumWithoutDesc " \ 146 | "(rule: `add-type-without-description`)" in stdout.out 147 | assert "⛔ Description for type `Field` was removed " \ 148 | "(rule: `remove-type-description`)" in stdout.out 149 | assert "⛔ `Field.calculus` description was removed " \ 150 | "(rule: `remove-field-description`)" in stdout.out 151 | assert "⛔ Field `c` was added to object type `Query` without a description for " \ 152 | "Query.c (rule: `add-field-without-description`)" in stdout.out 153 | assert "⛔ Enum value `VALUE_3` was added to `Enum` enum without a description " \ 154 | "(rule: `add-enum-value-without-description`)" in stdout.out 155 | assert "⛔ Description for enum value `VALUE_2` was removed " \ 156 | "(rule: `remove-enum-value-description`)" in stdout.out 157 | 158 | 159 | def test_schema_tolerant_mode(capsys): 160 | SCHEMA_FILE = 'tests/data/simple_schema.gql' 161 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_dangerous_changes.gql' 162 | args = parse_args([ 163 | '-o', SCHEMA_FILE, 164 | '--new-schema', ANOTHER_SCHEMA_FILE, 165 | '--tolerant' 166 | ]) 167 | exit_code = main(args) 168 | # In tolerant mode, dangerous changes don't cause an error exit code 169 | assert exit_code == 0 170 | 171 | stdout = capsys.readouterr() 172 | assert "✔️ Field `c` was added to object type `Query`" in stdout.out 173 | assert "⚠️ Default value for argument `x` on field `Field.calculus` changed from `0` to `100`" in stdout.out 174 | assert len(stdout.out.split('\n')) == 3 175 | 176 | 177 | def test_schema_strict_mode_with_breaking_changes_gives_error_exit_code(capsys): 178 | SCHEMA_FILE = 'tests/data/simple_schema.gql' 179 | ANOTHER_SCHEMA_FILE = 'tests/data/simple_schema_breaking_changes.gql' 180 | args = parse_args([ 181 | '-o', SCHEMA_FILE, 182 | '--new-schema', ANOTHER_SCHEMA_FILE, 183 | '--strict' 184 | ]) 185 | exit_code = main(args) 186 | assert exit_code == 1 187 | 188 | stdout = capsys.readouterr() 189 | assert '❌ Field `a` was removed from object type `Query`\n' in stdout.out 190 | assert len(stdout.out.split('\n')) == 2 191 | 192 | 193 | def test_cli_exits_normally_by_default_when_strict_or_tolerant_mode_is_specified(capsys): 194 | SCHEMA_FILE = 'tests/data/simple_schema.gql' 195 | BREAKING_CHANGES_SCHEMA = 'tests/data/simple_schema_breaking_changes.gql' 196 | args = parse_args([ 197 | '--old-schema', SCHEMA_FILE, 198 | '-n', BREAKING_CHANGES_SCHEMA, 199 | ]) 200 | exit_code = main(args) 201 | assert exit_code == 0 202 | 203 | stdout = capsys.readouterr() 204 | assert '❌ Field `a` was removed from object type `Query`\n' in stdout.out 205 | assert len(stdout.out.split('\n')) == 2 206 | 207 | 208 | def test_tolerant_mode_doesnt_allow_breaking_changes(capsys): 209 | SCHEMA_FILE = 'tests/data/simple_schema.gql' 210 | BREAKING_CHANGES_SCHEMA = 'tests/data/simple_schema_breaking_changes.gql' 211 | tolerant_args = parse_args([ 212 | '-o', SCHEMA_FILE, 213 | '-n', BREAKING_CHANGES_SCHEMA, 214 | '--tolerant' 215 | ]) 216 | default_args = parse_args([ 217 | '-o', SCHEMA_FILE, 218 | '-n', BREAKING_CHANGES_SCHEMA, 219 | ]) 220 | exit_code = main(tolerant_args) 221 | assert exit_code == 2 222 | 223 | exit_code = main(default_args) 224 | assert exit_code == 0 225 | 226 | 227 | def test_cli_function(capsys): 228 | SCHEMA_FILE = 'tests/data/simple_schema.gql' 229 | command_args = [ 230 | 'schemadiff', 231 | '-o', SCHEMA_FILE, 232 | '-n', SCHEMA_FILE, 233 | ] 234 | with patch.object(sys, 'argv', command_args): 235 | exit_code = cli() 236 | assert exit_code == 0 237 | assert capsys.readouterr().out == '🎉 Both schemas are equal!\n' 238 | -------------------------------------------------------------------------------- /docs/schema_loader.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | schemadiff.schema_loader API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 |
22 |

Module schemadiff.schema_loader

23 |
24 |
25 |
26 | 27 | Expand source code 28 | 29 |
from graphql import build_schema, GraphQLSchema
 30 | 
 31 | 
 32 | class SchemaLoader:
 33 |     """Represents a GraphQL Schema loaded from a string or file."""
 34 | 
 35 |     @classmethod
 36 |     def from_sdl(cls, schema_string: str) -> GraphQLSchema:
 37 |         return build_schema(schema_string)
 38 | 
 39 |     @classmethod
 40 |     def from_file(cls, filepath: str) -> GraphQLSchema:
 41 |         with open(filepath, encoding='utf-8') as f:
 42 |             schema_string = f.read()
 43 | 
 44 |         return cls.from_sdl(schema_string)
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |

Classes

55 |
56 |
57 | class SchemaLoader 58 |
59 |
60 |

Represents a GraphQL Schema loaded from a string or file.

61 |
62 | 63 | Expand source code 64 | 65 |
class SchemaLoader:
 66 |     """Represents a GraphQL Schema loaded from a string or file."""
 67 | 
 68 |     @classmethod
 69 |     def from_sdl(cls, schema_string: str) -> GraphQLSchema:
 70 |         return build_schema(schema_string)
 71 | 
 72 |     @classmethod
 73 |     def from_file(cls, filepath: str) -> GraphQLSchema:
 74 |         with open(filepath, encoding='utf-8') as f:
 75 |             schema_string = f.read()
 76 | 
 77 |         return cls.from_sdl(schema_string)
78 |
79 |

Static methods

80 |
81 |
82 | def from_file(filepath: str) ‑> graphql.type.schema.GraphQLSchema 83 |
84 |
85 |
86 |
87 | 88 | Expand source code 89 | 90 |
@classmethod
 91 | def from_file(cls, filepath: str) -> GraphQLSchema:
 92 |     with open(filepath, encoding='utf-8') as f:
 93 |         schema_string = f.read()
 94 | 
 95 |     return cls.from_sdl(schema_string)
96 |
97 |
98 |
99 | def from_sdl(schema_string: str) ‑> graphql.type.schema.GraphQLSchema 100 |
101 |
102 |
103 |
104 | 105 | Expand source code 106 | 107 |
@classmethod
108 | def from_sdl(cls, schema_string: str) -> GraphQLSchema:
109 |     return build_schema(schema_string)
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | 141 |
142 | 145 | 146 | -------------------------------------------------------------------------------- /tests/diff/test_directive.py: -------------------------------------------------------------------------------- 1 | from graphql import build_schema as schema 2 | 3 | from schemadiff.changes import Criticality 4 | from schemadiff.diff.schema import Schema 5 | 6 | 7 | def test_added_removed_directive(): 8 | no_directive = schema(""" 9 | type A { 10 | a: String 11 | } 12 | """) 13 | one_directive = schema(""" 14 | directive @somedir on FIELD_DEFINITION 15 | type A { 16 | a: String 17 | } 18 | """) 19 | diff = Schema(no_directive, one_directive).diff() 20 | assert diff and len(diff) == 1 21 | assert diff[0].message == "Directive `@somedir` was added to use on `FIELD_DEFINITION`" 22 | assert diff[0].path == '@somedir' 23 | assert diff[0].criticality == Criticality.safe() 24 | 25 | diff = Schema(one_directive, no_directive).diff() 26 | assert diff and len(diff) == 1 27 | assert diff[0].message == "Directive `@somedir` was removed" 28 | assert diff[0].path == '@somedir' 29 | assert diff[0].criticality == Criticality.breaking('Removing a directive may break clients that depend on them.') 30 | 31 | two_locations = schema(""" 32 | directive @somedir on FIELD_DEFINITION | QUERY 33 | type A { 34 | a: String 35 | } 36 | """) 37 | diff = Schema(no_directive, two_locations).diff() 38 | assert diff and len(diff) == 1 39 | assert diff[0].message == "Directive `@somedir` was added to use on `FIELD_DEFINITION | QUERY`" 40 | assert diff[0].path == '@somedir' 41 | assert diff[0].criticality == Criticality.safe() 42 | 43 | 44 | def test_description_changed(): 45 | old_desc = schema(''' 46 | """directive desc""" 47 | directive @somedir on FIELD_DEFINITION 48 | 49 | directive @nodesc on FIELD_DEFINITION 50 | 51 | """to be removed""" 52 | directive @toberemoveddesc on FIELD_DEFINITION 53 | 54 | type A { 55 | a: String 56 | } 57 | ''') 58 | new_desc = schema(''' 59 | """updated desc""" 60 | directive @somedir on FIELD_DEFINITION 61 | 62 | """added desc""" 63 | directive @nodesc on FIELD_DEFINITION 64 | 65 | directive @toberemoveddesc on FIELD_DEFINITION 66 | 67 | type A { 68 | a: String 69 | } 70 | ''') 71 | diff = Schema(old_desc, new_desc).diff() 72 | assert diff and len(diff) == 3 73 | expected_diff = { 74 | "Description for directive `@somedir` changed from `directive desc` to `updated desc`", 75 | "Description for directive `@nodesc` changed from `None` to `added desc`", 76 | "Description for directive `@toberemoveddesc` changed from `to be removed` to `None`", 77 | } 78 | expected_paths = {'@somedir', '@nodesc', '@toberemoveddesc'} 79 | for change in diff: 80 | assert change.message in expected_diff 81 | assert change.path in expected_paths 82 | assert change.criticality == Criticality.safe() 83 | 84 | 85 | def test_directive_location_added_and_removed(): 86 | one_location = schema(""" 87 | directive @somedir on FIELD_DEFINITION 88 | type A { 89 | a: String 90 | } 91 | """) 92 | two_locations = schema(""" 93 | directive @somedir on FIELD_DEFINITION | FIELD 94 | type A { 95 | a: String 96 | } 97 | """) 98 | diff = Schema(one_location, two_locations).diff() 99 | assert diff and len(diff) == 1 100 | expected_message = ( 101 | "Directive locations of `@somedir` changed from `FIELD_DEFINITION` to `FIELD_DEFINITION | FIELD`" 102 | ) 103 | assert diff[0].message == expected_message 104 | assert diff[0].path == '@somedir' 105 | assert diff[0].criticality == Criticality.safe() 106 | 107 | diff = Schema(two_locations, one_location).diff() 108 | assert diff and len(diff) == 1 109 | expected_message = ( 110 | "Directive locations of `@somedir` changed from `FIELD_DEFINITION | FIELD` to `FIELD_DEFINITION`" 111 | ) 112 | assert diff[0].message == expected_message 113 | assert diff[0].path == '@somedir' 114 | assert diff[0].criticality == Criticality.breaking( 115 | 'Removing a directive location will break any instance of its usage. Be sure no one uses it before removing it' 116 | ) 117 | 118 | 119 | def test_directive_argument_changes(): 120 | name_arg = schema(""" 121 | directive @somedir(name: String) on FIELD_DEFINITION 122 | type A { 123 | a: String 124 | } 125 | """) 126 | id_arg = schema(""" 127 | directive @somedir(id: ID) on FIELD_DEFINITION 128 | type A { 129 | a: String 130 | } 131 | """) 132 | diff = Schema(name_arg, id_arg).diff() 133 | assert diff and len(diff) == 2 134 | expected_message = ( 135 | 'Removed argument `name: String` from `@somedir` directive' 136 | "Added argument `id: ID` to `@somedir` directive" 137 | ) 138 | for change in diff: 139 | assert change.message in expected_message 140 | assert change.path == '@somedir' 141 | assert change.criticality == Criticality.breaking( 142 | 'Removing a directive argument will break existing usages of the argument' 143 | ) if 'Removed' in change.message else Criticality.safe() 144 | 145 | 146 | def test_directive_description_changed(): 147 | no_desc = schema(""" 148 | directive @my_directive on FIELD 149 | type A { 150 | a: String 151 | } 152 | """) 153 | with_desc = schema(''' 154 | """directive desc""" 155 | directive @my_directive on FIELD 156 | type A { 157 | a: String 158 | } 159 | ''') 160 | new_desc = schema(''' 161 | """new description""" 162 | directive @my_directive on FIELD 163 | type A { 164 | a: String 165 | } 166 | ''') 167 | diff = Schema(no_desc, with_desc).diff() 168 | assert diff and len(diff) == 1 169 | expected_message = ( 170 | 'Description for directive `@my_directive` changed from `None` to `directive desc`' 171 | ) 172 | assert diff[0].message == expected_message 173 | assert diff[0].path == '@my_directive' 174 | assert diff[0].criticality == Criticality.safe() 175 | 176 | diff = Schema(with_desc, new_desc).diff() 177 | assert diff and len(diff) == 1 178 | expected_message = ( 179 | 'Description for directive `@my_directive` changed from `directive desc` to `new description`' 180 | ) 181 | assert diff[0].message == expected_message 182 | assert diff[0].path == '@my_directive' 183 | assert diff[0].criticality == Criticality.safe() 184 | 185 | diff = Schema(with_desc, no_desc).diff() 186 | assert diff and len(diff) == 1 187 | expected_message = ( 188 | 'Description for directive `@my_directive` changed from `directive desc` to `None`' 189 | ) 190 | assert diff[0].message == expected_message 191 | assert diff[0].path == '@my_directive' 192 | assert diff[0].criticality == Criticality.safe() 193 | 194 | 195 | def test_directive_default_value_changed(): 196 | default_100 = schema(""" 197 | directive @limit(number: Int=100) on FIELD_DEFINITION 198 | type A { 199 | a: String 200 | } 201 | """) 202 | default_0 = schema(""" 203 | directive @limit(number: Int=0) on FIELD_DEFINITION 204 | type A { 205 | a: String 206 | } 207 | """) 208 | 209 | diff = Schema(default_100, default_0).diff() 210 | assert diff and len(diff) == 1 211 | expected_message = ( 212 | 'Default value for argument `number` on `@limit` directive changed from `100` to `0`' 213 | ) 214 | assert diff[0].message == expected_message 215 | assert diff[0].path == '@limit' 216 | assert diff[0].criticality == Criticality.dangerous( 217 | 'Changing the default value for an argument may change ' 218 | 'the runtime behaviour of a field if it was never provided.' 219 | ) 220 | 221 | 222 | def test_directive_argument_type_changed(): 223 | int_arg = schema(""" 224 | directive @limit(number: Int) on FIELD_DEFINITION 225 | type A { 226 | a: String 227 | } 228 | """) 229 | float_arg = schema(""" 230 | directive @limit(number: Float) on FIELD_DEFINITION 231 | type A { 232 | a: String 233 | } 234 | """) 235 | 236 | diff = Schema(int_arg, float_arg).diff() 237 | assert diff and len(diff) == 1 238 | expected_message = ( 239 | "Type for argument `number` on `@limit` directive changed from `Int` to `Float`" 240 | ) 241 | assert diff[0].message == expected_message 242 | assert diff[0].path == '@limit' 243 | assert diff[0].criticality == Criticality.breaking('Changing the argument type is a breaking change') 244 | 245 | 246 | def test_directive_argument_description_changed(): 247 | no_desc = schema(""" 248 | directive @limit( 249 | number: Int 250 | ) on FIELD_DEFINITION 251 | 252 | type A { 253 | a: String 254 | } 255 | """) 256 | a_desc = schema(""" 257 | directive @limit( 258 | "number limit" 259 | number: Int 260 | ) on FIELD_DEFINITION 261 | 262 | type A { 263 | a: String 264 | } 265 | """) 266 | other_desc = schema(""" 267 | directive @limit( 268 | "field limit" 269 | number: Int 270 | ) on FIELD_DEFINITION 271 | 272 | type A { 273 | a: String 274 | } 275 | """) 276 | 277 | diff = Schema(no_desc, a_desc).diff() 278 | assert diff and len(diff) == 1 279 | expected_message = ( 280 | "Description for argument `number` on `@limit` directive changed from `None` to `number limit`" 281 | ) 282 | assert diff[0].message == expected_message 283 | assert diff[0].path == '@limit' 284 | assert diff[0].criticality == Criticality.safe() 285 | 286 | diff = Schema(a_desc, other_desc).diff() 287 | assert diff and len(diff) == 1 288 | expected_message = ( 289 | "Description for argument `number` on `@limit` directive changed from `number limit` to `field limit`" 290 | ) 291 | assert diff[0].message == expected_message 292 | assert diff[0].path == '@limit' 293 | assert diff[0].criticality == Criticality.safe() 294 | 295 | diff = Schema(other_desc, no_desc).diff() 296 | assert diff and len(diff) == 1 297 | expected_message = ( 298 | "Description for argument `number` on `@limit` directive changed from `field limit` to `None`" 299 | ) 300 | assert diff[0].message == expected_message 301 | assert diff[0].path == '@limit' 302 | assert diff[0].criticality == Criticality.safe() 303 | -------------------------------------------------------------------------------- /tests/test_validation_rules.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from graphql import build_schema as schema 3 | 4 | from schemadiff.changes import Change, Criticality 5 | from schemadiff.changes.field import FieldArgumentAdded 6 | from schemadiff.changes.object import ObjectTypeFieldAdded 7 | from schemadiff.validation import validate_changes 8 | from schemadiff.validation_rules import ( 9 | ValidationRule, 10 | AddFieldWithoutDescription, 11 | AddTypeWithoutDescription, 12 | AddEnumValueWithoutDescription, 13 | RemoveEnumValueDescription, 14 | RemoveTypeDescription, 15 | ) 16 | from schemadiff.diff.schema import Schema 17 | 18 | 19 | @pytest.mark.parametrize('rule', ValidationRule.__subclasses__()) 20 | def test_is_valid_defaults_to_true_for_any_other_change(rule): 21 | class UnexpectedChange(Change): 22 | criticality = Criticality.safe() 23 | 24 | @property 25 | def message(self): 26 | return "" 27 | 28 | @property 29 | def path(self): 30 | return "" 31 | 32 | assert rule(UnexpectedChange()).is_valid() is True 33 | 34 | 35 | def test_type_added_with_desc(): 36 | a = schema(''' 37 | type MyType{ 38 | a: Int 39 | } 40 | ''') 41 | b = schema(''' 42 | type MyType{ 43 | a: Int 44 | } 45 | """This has desc""" 46 | type NewType{ 47 | """And its field also has a desc""" 48 | b: String! 49 | } 50 | ''') 51 | diff = Schema(a, b).diff() 52 | assert AddTypeWithoutDescription(diff[0]).is_valid() is True 53 | 54 | 55 | def test_type_added_with_desc_but_missing_desc_on_its_fields(): 56 | a = schema(''' 57 | type MyType{ 58 | a: Int 59 | } 60 | ''') 61 | b = schema(''' 62 | type MyType{ 63 | a: Int 64 | } 65 | """This has desc""" 66 | type NewType{ 67 | """This one has desc""" 68 | b: String! 69 | c: Int! 70 | } 71 | ''') 72 | diff = Schema(a, b).diff() 73 | assert AddTypeWithoutDescription(diff[0]).is_valid() is False 74 | assert AddTypeWithoutDescription(diff[0]).message == ( 75 | 'Type `NewType` was added without a description for NewType (rule: ' 76 | '`add-type-without-description`).' 77 | ) 78 | 79 | 80 | def test_type_added_without_desc(): 81 | a = schema(''' 82 | type MyType{ 83 | a: Int 84 | } 85 | ''') 86 | b = schema(''' 87 | type MyType{ 88 | a: Int 89 | } 90 | type NewType{ 91 | b: String! 92 | } 93 | ''') 94 | diff = Schema(a, b).diff() 95 | assert AddTypeWithoutDescription(diff[0]).is_valid() is False 96 | 97 | 98 | def test_type_changed_desc_removed(): 99 | a = schema(''' 100 | """This has desc""" 101 | type MyType{ 102 | a: Int 103 | } 104 | ''') 105 | b = schema(''' 106 | type MyType{ 107 | a: Int 108 | } 109 | ''') 110 | c = schema(''' 111 | """""" 112 | type MyType{ 113 | a: Int 114 | } 115 | ''') 116 | diff = Schema(a, b).diff() 117 | assert RemoveTypeDescription(diff[0]).is_valid() is False 118 | 119 | diff = Schema(a, c).diff() 120 | assert RemoveTypeDescription(diff[0]).is_valid() is False 121 | 122 | 123 | def test_field_added_without_desc(): 124 | a = schema(''' 125 | type MyType{ 126 | a: Int 127 | } 128 | ''') 129 | b = schema(''' 130 | type MyType{ 131 | a: Int 132 | b: String! 133 | } 134 | ''') 135 | c = schema(''' 136 | type MyType{ 137 | a: Int 138 | """WithDesc""" 139 | b: String! 140 | } 141 | ''') 142 | diff = Schema(a, b).diff() 143 | assert AddFieldWithoutDescription(diff[0]).is_valid() is False 144 | 145 | diff = Schema(a, c).diff() 146 | assert AddFieldWithoutDescription(diff[0]).is_valid() is True 147 | 148 | 149 | def test_enum_added_without_desc(): 150 | a = schema(''' 151 | enum Letters { 152 | A 153 | } 154 | ''') 155 | b = schema(''' 156 | enum Letters { 157 | A 158 | B 159 | } 160 | ''') 161 | c = schema(''' 162 | enum Letters { 163 | A 164 | """WithDesc""" 165 | B 166 | } 167 | ''') 168 | diff = Schema(a, b).diff() 169 | assert AddEnumValueWithoutDescription(diff[0]).is_valid() is False 170 | 171 | diff = Schema(a, c).diff() 172 | assert AddEnumValueWithoutDescription(diff[0]).is_valid() is True 173 | 174 | 175 | def test_enum_value_removing_desc(): 176 | a = schema(''' 177 | enum Letters { 178 | """WithDesc""" 179 | A 180 | } 181 | ''') 182 | b = schema(''' 183 | enum Letters { 184 | A 185 | } 186 | ''') 187 | diff = Schema(a, b).diff() 188 | assert RemoveEnumValueDescription(diff[0]).is_valid() is False 189 | 190 | 191 | def test_schema_added_field_no_desc(): 192 | 193 | schema_restrictions = ['add-field-without-description'] 194 | 195 | old_schema = schema(""" 196 | schema { 197 | query: Query 198 | } 199 | 200 | type Query { 201 | field: String! 202 | } 203 | 204 | type AddedType { 205 | added: Int 206 | } 207 | """) 208 | new_schema = schema(""" 209 | schema { 210 | query: Query 211 | } 212 | 213 | type Query { 214 | field: String! 215 | } 216 | 217 | type AddedType { 218 | added: Int 219 | other: Int 220 | } 221 | """) 222 | diff = Schema(old_schema, new_schema).diff() 223 | # Type Int was also added but its ignored because its a primitive. 224 | assert diff and len(diff) == 1 225 | error_msg = ( 226 | 'Field `other` was added to object type `AddedType` without a description for AddedType.other ' 227 | '(rule: `add-field-without-description`).' 228 | ) 229 | result = validate_changes(diff, schema_restrictions) 230 | assert result.ok is False 231 | assert result.errors and len(result.errors) == 1 232 | assert result.errors[0].reason == error_msg 233 | assert diff[0].path == 'AddedType.other' 234 | assert result.errors[0].change.path == 'AddedType.other' 235 | assert result.errors[0].rule == 'add-field-without-description' 236 | 237 | 238 | # Register the new validation rule for the following two tests 239 | class FieldHasTooManyArguments(ValidationRule): 240 | """Restrict adding fields with too many top level arguments""" 241 | 242 | name = "field-has-too-many-arguments" 243 | limit = 10 244 | 245 | def is_valid(self) -> bool: 246 | if not isinstance(self.change, (ObjectTypeFieldAdded, FieldArgumentAdded)): 247 | return True 248 | 249 | if len(self.args) > self.limit: 250 | return False 251 | else: 252 | return True 253 | 254 | @property 255 | def args(self): 256 | return self.change.field.args or {} 257 | 258 | @property 259 | def message(self): 260 | return f"Field `{self.change.parent.name}.{self.change.field_name}` has too many arguments " \ 261 | f"({len(self.args)}>{self.limit}). Rule: {self.name}" 262 | 263 | 264 | def test_cant_create_mutation_with_more_than_10_arguments(): 265 | schema_restrictions = [FieldHasTooManyArguments.name] 266 | 267 | old_schema = schema(""" 268 | schema { 269 | mutation: Mutation 270 | } 271 | 272 | type Mutation { 273 | field: Int 274 | } 275 | """) 276 | 277 | new_schema = schema(""" 278 | schema { 279 | mutation: Mutation 280 | } 281 | 282 | type Mutation { 283 | field: Int 284 | mutation_with_too_many_args( 285 | a1: Int, a2: Int, a3: Int, a4: Int, a5: Int, a6: Int, a7: Int, a8: Int, a9: Int, a10: Int, a11: Int 286 | ): Int 287 | } 288 | """) 289 | 290 | diff = Schema(old_schema, new_schema).diff() 291 | # Type Int was also added but its ignored because its a primitive. 292 | assert diff and len(diff) == 1 293 | error_msg = ( 294 | 'Field `Mutation.mutation_with_too_many_args` has too many arguments (11>10). ' 295 | 'Rule: field-has-too-many-arguments' 296 | ) 297 | result = validate_changes(diff, schema_restrictions) 298 | assert result.ok is False 299 | assert result.errors and len(result.errors) == 1 300 | assert result.errors[0].reason == error_msg 301 | assert result.errors[0].change.path == 'Mutation.mutation_with_too_many_args' 302 | assert result.errors[0].rule == FieldHasTooManyArguments.name 303 | 304 | 305 | def test_cant_add_arguments_to_mutation_if_exceeds_10_args(): 306 | schema_restrictions = [FieldHasTooManyArguments.name] 307 | 308 | old_schema = schema(""" 309 | schema { 310 | mutation: Mutation 311 | } 312 | 313 | type Mutation { 314 | field: Int 315 | mutation_with_too_many_args( 316 | a1: Int, a2: Int, a3: Int, a4: Int, a5: Int, a6: Int, a7: Int, a8: Int, a9: Int, a10: Int 317 | ): Int 318 | } 319 | """) 320 | 321 | new_schema = schema(""" 322 | schema { 323 | mutation: Mutation 324 | } 325 | 326 | type Mutation { 327 | field: Int 328 | mutation_with_too_many_args( 329 | a1: Int, a2: Int, a3: Int, a4: Int, a5: Int, a6: Int, a7: Int, a8: Int, a9: Int, a10: Int, a11: Int 330 | ): Int 331 | } 332 | """) 333 | 334 | diff = Schema(old_schema, new_schema).diff() 335 | # Type Int was also added but its ignored because its a primitive. 336 | assert diff and len(diff) == 1 337 | error_msg = ( 338 | 'Field `Mutation.mutation_with_too_many_args` has too many arguments (11>10). ' 339 | 'Rule: field-has-too-many-arguments' 340 | ) 341 | result = validate_changes(diff, schema_restrictions) 342 | assert result.ok is False 343 | assert result.errors and len(result.errors) == 1 344 | assert result.errors[0].reason == error_msg 345 | assert result.errors[0].change.message == "Argument `a11: Int` added to `Mutation.mutation_with_too_many_args`" 346 | assert result.errors[0].change.checksum() == "221964c2ab5bbc6bd1ed19bcd8d69e70" 347 | assert result.errors[0].rule == FieldHasTooManyArguments.name 348 | 349 | assert diff[0].path == 'Mutation.mutation_with_too_many_args' 350 | 351 | 352 | def test_can_allow_rule_infractions(): 353 | CHANGE_ID = '221964c2ab5bbc6bd1ed19bcd8d69e70' 354 | validation_rules = [FieldHasTooManyArguments.name] 355 | 356 | old_schema = schema(""" 357 | schema { 358 | mutation: Mutation 359 | } 360 | 361 | type Mutation { 362 | field: Int 363 | mutation_with_too_many_args( 364 | a1: Int, a2: Int, a3: Int, a4: Int, a5: Int, a6: Int, a7: Int, a8: Int, a9: Int, a10: Int 365 | ): Int 366 | } 367 | """) 368 | 369 | new_schema = schema(""" 370 | schema { 371 | mutation: Mutation 372 | } 373 | 374 | type Mutation { 375 | field: Int 376 | mutation_with_too_many_args( 377 | a1: Int, a2: Int, a3: Int, a4: Int, a5: Int, a6: Int, a7: Int, a8: Int, a9: Int, a10: Int, a11: Int 378 | ): Int 379 | } 380 | """) 381 | 382 | changes = Schema(old_schema, new_schema).diff() 383 | result = validate_changes(changes, validation_rules) 384 | assert result.ok is False 385 | assert result.errors[0].change.checksum() == CHANGE_ID 386 | result = validate_changes(changes, validation_rules, allowed_changes={CHANGE_ID}) 387 | assert result.ok is True 388 | assert result.errors == [] 389 | -------------------------------------------------------------------------------- /docs/allow_list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | schemadiff.allow_list API documentation 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 |
22 |

Module schemadiff.allow_list

23 |
24 |
25 |
26 | 27 | Expand source code 28 | 29 |
import json
 30 | import re
 31 | from json import JSONDecodeError
 32 | 
 33 | 
 34 | class InvalidAllowlist(Exception):
 35 |     """Exception raised when the user provides an invalid json file of allowed changes"""
 36 | 
 37 | 
 38 | def read_allowed_changes(file_content):
 39 |     """Read a json file that defines a mapping of changes checksums to the reasons of why it is allowed.
 40 | 
 41 |     Compliant formats:
 42 |         {
 43 |             '0cc175b9c0f1b6a831c399e269772661': 'Removed field `MyField` because clients don't use it',
 44 |             '92eb5ffee6ae2fec3ad71c777531578f': 'Removed argument `Arg` because it became redundant',
 45 |         }
 46 |         But the value can be as detailed as the user wants (as long as the key remains the change checksum)
 47 |         {
 48 |             '0cc175b9c0f1b6a831c399e269772661': {
 49 |                 'date': '2030-01-01 15:00:00,
 50 |                 'reason': 'my reason',
 51 |                 'message': 'Field `a` was removed from object type `Query`'
 52 |             },
 53 |             '92eb5ffee6ae2fec3ad71c777531578f': {
 54 |                 'date': '2031-01-01 23:59:00,
 55 |                 'reason': 'my new reason',
 56 |                 'message': 'Field `b` was removed from object type `MyType`'
 57 |             },
 58 |         }
 59 |     """
 60 |     try:
 61 |         allowlist = json.loads(file_content)
 62 |     except JSONDecodeError as e:
 63 |         raise InvalidAllowlist("Invalid json format provided.") from e
 64 |     if not isinstance(allowlist, dict):
 65 |         raise InvalidAllowlist("Allowlist must be a mapping.")
 66 | 
 67 |     CHECKSUM_REGEX = re.compile(r'[a-fA-F0-9]{32}')
 68 |     if any(not CHECKSUM_REGEX.match(checksum) for checksum in allowlist.keys()):
 69 |         raise InvalidAllowlist("All keys must be a valid md5 checksum")
 70 | 
 71 |     return allowlist
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |

Functions

80 |
81 |
82 | def read_allowed_changes(file_content) 83 |
84 |
85 |

Read a json file that defines a mapping of changes checksums to the reasons of why it is allowed.

86 |

Compliant formats: 87 | { 88 | '0cc175b9c0f1b6a831c399e269772661': 'Removed field MyField because clients don't use it', 89 | '92eb5ffee6ae2fec3ad71c777531578f': 'Removed argument Arg because it became redundant', 90 | } 91 | But the value can be as detailed as the user wants (as long as the key remains the change checksum) 92 | { 93 | '0cc175b9c0f1b6a831c399e269772661': { 94 | 'date': '2030-01-01 15:00:00, 95 | 'reason': 'my reason', 96 | 'message': 'Field a was removed from object type Query' 97 | }, 98 | '92eb5ffee6ae2fec3ad71c777531578f': { 99 | 'date': '2031-01-01 23:59:00, 100 | 'reason': 'my new reason', 101 | 'message': 'Field b was removed from object type MyType' 102 | }, 103 | }

104 |
105 | 106 | Expand source code 107 | 108 |
def read_allowed_changes(file_content):
109 |     """Read a json file that defines a mapping of changes checksums to the reasons of why it is allowed.
110 | 
111 |     Compliant formats:
112 |         {
113 |             '0cc175b9c0f1b6a831c399e269772661': 'Removed field `MyField` because clients don't use it',
114 |             '92eb5ffee6ae2fec3ad71c777531578f': 'Removed argument `Arg` because it became redundant',
115 |         }
116 |         But the value can be as detailed as the user wants (as long as the key remains the change checksum)
117 |         {
118 |             '0cc175b9c0f1b6a831c399e269772661': {
119 |                 'date': '2030-01-01 15:00:00,
120 |                 'reason': 'my reason',
121 |                 'message': 'Field `a` was removed from object type `Query`'
122 |             },
123 |             '92eb5ffee6ae2fec3ad71c777531578f': {
124 |                 'date': '2031-01-01 23:59:00,
125 |                 'reason': 'my new reason',
126 |                 'message': 'Field `b` was removed from object type `MyType`'
127 |             },
128 |         }
129 |     """
130 |     try:
131 |         allowlist = json.loads(file_content)
132 |     except JSONDecodeError as e:
133 |         raise InvalidAllowlist("Invalid json format provided.") from e
134 |     if not isinstance(allowlist, dict):
135 |         raise InvalidAllowlist("Allowlist must be a mapping.")
136 | 
137 |     CHECKSUM_REGEX = re.compile(r'[a-fA-F0-9]{32}')
138 |     if any(not CHECKSUM_REGEX.match(checksum) for checksum in allowlist.keys()):
139 |         raise InvalidAllowlist("All keys must be a valid md5 checksum")
140 | 
141 |     return allowlist
142 |
143 |
144 |
145 |
146 |
147 |

Classes

148 |
149 |
150 | class InvalidAllowlist 151 | (*args, **kwargs) 152 |
153 |
154 |

Exception raised when the user provides an invalid json file of allowed changes

155 |
156 | 157 | Expand source code 158 | 159 |
class InvalidAllowlist(Exception):
160 |     """Exception raised when the user provides an invalid json file of allowed changes"""
161 |
162 |

Ancestors

163 |
    164 |
  • builtins.Exception
  • 165 |
  • builtins.BaseException
  • 166 |
167 |
168 |
169 |
170 |
171 | 196 |
197 | 200 | 201 | --------------------------------------------------------------------------------