forked from pantsbuild/pants
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add OpenAPI linting with Spectral (pantsbuild#17137)
This PR adds linting support for `openapi_document()` using [Spectral](https://github.com/stoplightio/spectral), though the implementation currently only supports Spectral's own ruleset `spectral:oas`. I do plan on adding support for a [custom ruleset](https://meta.stoplight.io/docs/spectral/e5b9616d6d50c-custom-rulesets) file in the near future, but since those files have nested `extends:` capabilities it seemed like a party to save for another day ([custom functions](https://meta.stoplight.io/docs/spectral/a781e290eb9f9-custom-functions) is future Jonas' problem as well). [ci skip-rust] [ci skip-build-wheels]
- Loading branch information
Showing
11 changed files
with
354 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
4 changes: 4 additions & 0 deletions
4
src/python/pants/backend/experimental/openapi/lint/spectral/BUILD
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
python_sources() |
Empty file.
9 changes: 9 additions & 0 deletions
9
src/python/pants/backend/experimental/openapi/lint/spectral/register.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
from pants.backend.openapi.lint.spectral import rules as spectral_rules | ||
from pants.backend.openapi.lint.spectral import skip_field | ||
|
||
|
||
def rules(): | ||
return (*spectral_rules.rules(), *skip_field.rules()) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
python_sources() | ||
python_tests(name="tests") |
Empty file.
120 changes: 120 additions & 0 deletions
120
src/python/pants/backend/openapi/lint/spectral/rules.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,120 @@ | ||
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
from dataclasses import dataclass | ||
|
||
from pants.backend.openapi.lint.spectral.skip_field import SkipSpectralField | ||
from pants.backend.openapi.lint.spectral.subsystem import SpectralSubsystem | ||
from pants.backend.openapi.target_types import ( | ||
OpenApiDocumentDependenciesField, | ||
OpenApiDocumentField, | ||
OpenApiSourceField, | ||
) | ||
from pants.core.goals.lint import LintResult, LintTargetsRequest, PartitionerType | ||
from pants.core.util_rules.external_tool import DownloadedExternalTool, ExternalToolRequest | ||
from pants.core.util_rules.source_files import SourceFiles, SourceFilesRequest | ||
from pants.engine.fs import CreateDigest, Digest, FileContent, MergeDigests | ||
from pants.engine.platform import Platform | ||
from pants.engine.process import FallibleProcessResult, Process | ||
from pants.engine.rules import Get, MultiGet, collect_rules, rule | ||
from pants.engine.target import FieldSet, Target, TransitiveTargets, TransitiveTargetsRequest | ||
from pants.util.logging import LogLevel | ||
from pants.util.strutil import pluralize | ||
|
||
|
||
@dataclass(frozen=True) | ||
class SpectralFieldSet(FieldSet): | ||
required_fields = (OpenApiDocumentField,) | ||
|
||
sources: OpenApiDocumentField | ||
dependencies: OpenApiDocumentDependenciesField | ||
|
||
@classmethod | ||
def opt_out(cls, tgt: Target) -> bool: | ||
return tgt.get(SkipSpectralField).value | ||
|
||
|
||
class SpectralRequest(LintTargetsRequest): | ||
field_set_type = SpectralFieldSet | ||
tool_subsystem = SpectralSubsystem | ||
partitioner_type = PartitionerType.DEFAULT_SINGLE_PARTITION | ||
|
||
|
||
@rule(desc="Lint with Spectral", level=LogLevel.DEBUG) | ||
async def run_spectral( | ||
request: SpectralRequest.SubPartition[SpectralFieldSet], | ||
spectral: SpectralSubsystem, | ||
platform: Platform, | ||
) -> LintResult: | ||
transitive_targets = await Get( | ||
TransitiveTargets, | ||
TransitiveTargetsRequest((field_set.address for field_set in request.elements)), | ||
) | ||
|
||
all_sources_request = Get( | ||
SourceFiles, | ||
SourceFilesRequest( | ||
tgt[OpenApiSourceField] | ||
for tgt in transitive_targets.closure | ||
if tgt.has_field(OpenApiSourceField) | ||
), | ||
) | ||
target_sources_request = Get( | ||
SourceFiles, | ||
SourceFilesRequest( | ||
(field_set.sources for field_set in request.elements), | ||
for_sources_types=(OpenApiDocumentField,), | ||
enable_codegen=True, | ||
), | ||
) | ||
|
||
ruleset_digest_get = Get( | ||
Digest, CreateDigest([FileContent(".spectral.yaml", b'extends: "spectral:oas"\n')]) | ||
) | ||
download_spectral_get = Get( | ||
DownloadedExternalTool, ExternalToolRequest, spectral.get_request(platform) | ||
) | ||
|
||
target_sources, all_sources, downloaded_spectral, ruleset_digest = await MultiGet( | ||
target_sources_request, all_sources_request, download_spectral_get, ruleset_digest_get | ||
) | ||
|
||
input_digest = await Get( | ||
Digest, | ||
MergeDigests( | ||
( | ||
target_sources.snapshot.digest, | ||
all_sources.snapshot.digest, | ||
downloaded_spectral.digest, | ||
ruleset_digest, | ||
) | ||
), | ||
) | ||
|
||
process_result = await Get( | ||
FallibleProcessResult, | ||
Process( | ||
argv=[ | ||
downloaded_spectral.exe, | ||
"lint", | ||
"--display-only-failures", | ||
"--ruleset", | ||
".spectral.yaml", | ||
*spectral.args, | ||
*target_sources.snapshot.files, | ||
], | ||
input_digest=input_digest, | ||
description=f"Run Spectral on {pluralize(len(request.elements), 'file')}.", | ||
level=LogLevel.DEBUG, | ||
), | ||
) | ||
|
||
return LintResult.from_fallible_process_result( | ||
process_result, linter_name=SpectralRequest.tool_name | ||
) | ||
|
||
|
||
def rules(): | ||
return [ | ||
*collect_rules(), | ||
*SpectralRequest.rules(), | ||
] |
163 changes: 163 additions & 0 deletions
163
src/python/pants/backend/openapi/lint/spectral/rules_integration_test.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
from __future__ import annotations | ||
|
||
from textwrap import dedent | ||
|
||
import pytest | ||
|
||
from pants.backend.experimental.openapi.register import rules as target_types_rules | ||
from pants.backend.openapi.lint.spectral.rules import SpectralFieldSet, SpectralRequest | ||
from pants.backend.openapi.lint.spectral.rules import rules as spectral_rules | ||
from pants.backend.openapi.sample.resources import PETSTORE_SAMPLE_SPEC | ||
from pants.backend.openapi.target_types import ( | ||
OpenApiDocumentGeneratorTarget, | ||
OpenApiSourceGeneratorTarget, | ||
) | ||
from pants.core.goals.lint import LintResult, Partitions | ||
from pants.core.util_rules import config_files, external_tool, stripped_source_files | ||
from pants.engine.addresses import Address | ||
from pants.engine.target import Target | ||
from pants.testutil.rule_runner import QueryRule, RuleRunner | ||
|
||
|
||
@pytest.fixture | ||
def rule_runner() -> RuleRunner: | ||
return RuleRunner( | ||
rules=[ | ||
*spectral_rules(), | ||
*config_files.rules(), | ||
*external_tool.rules(), | ||
*stripped_source_files.rules(), | ||
*target_types_rules(), | ||
QueryRule(Partitions, [SpectralRequest.PartitionRequest]), | ||
QueryRule(LintResult, [SpectralRequest.SubPartition]), | ||
], | ||
target_types=[OpenApiDocumentGeneratorTarget, OpenApiSourceGeneratorTarget], | ||
) | ||
|
||
|
||
GOOD_FILE = """ | ||
openapi: 3.0.0 | ||
info: | ||
title: Example | ||
version: 1.0.0 | ||
paths: {} | ||
""" | ||
|
||
BAD_FILE = PETSTORE_SAMPLE_SPEC | ||
|
||
|
||
def run_spectral( | ||
rule_runner: RuleRunner, | ||
targets: list[Target], | ||
*, | ||
extra_args: list[str] | None = None, | ||
) -> tuple[LintResult, ...]: | ||
rule_runner.set_options( | ||
[ | ||
"--backend-packages=pants.backend.experimental.openapi.lint.spectral", | ||
*(extra_args or ()), | ||
], | ||
env_inherit={"PATH"}, | ||
) | ||
partition = rule_runner.request( | ||
Partitions[SpectralFieldSet], | ||
[SpectralRequest.PartitionRequest(tuple(SpectralFieldSet.create(tgt) for tgt in targets))], | ||
) | ||
results = [] | ||
for key, subpartition in partition.items(): | ||
result = rule_runner.request( | ||
LintResult, | ||
[SpectralRequest.SubPartition(subpartition, key)], | ||
) | ||
results.append(result) | ||
return tuple(results) | ||
|
||
|
||
def assert_success( | ||
rule_runner: RuleRunner, | ||
target: Target, | ||
*, | ||
extra_args: list[str] | None = None, | ||
) -> None: | ||
result = run_spectral(rule_runner, [target], extra_args=extra_args) | ||
assert len(result) == 1 | ||
assert result[0].exit_code == 0 | ||
assert result[0].stdout == "No results with a severity of 'error' found!\n" | ||
assert not result[0].stderr | ||
|
||
|
||
def test_passing(rule_runner: RuleRunner) -> None: | ||
rule_runner.write_files({"openapi.yaml": GOOD_FILE, "BUILD": "openapi_documents(name='t')"}) | ||
tgt = rule_runner.get_target(Address("", target_name="t", relative_file_path="openapi.yaml")) | ||
assert_success(rule_runner, tgt) | ||
|
||
|
||
def test_failing(rule_runner: RuleRunner) -> None: | ||
rule_runner.write_files({"openapi.yaml": BAD_FILE, "BUILD": "openapi_documents(name='t')"}) | ||
tgt = rule_runner.get_target(Address("", target_name="t", relative_file_path="openapi.yaml")) | ||
result = run_spectral(rule_runner, [tgt]) | ||
assert len(result) == 1 | ||
assert result[0].exit_code == 1 | ||
assert "openapi.yaml" in result[0].stdout | ||
|
||
|
||
def test_multiple_targets(rule_runner: RuleRunner) -> None: | ||
rule_runner.write_files( | ||
{ | ||
"good.yaml": GOOD_FILE, | ||
"bad.yaml": BAD_FILE, | ||
"BUILD": "openapi_documents(name='t', sources=['good.yaml', 'bad.yaml'])", | ||
} | ||
) | ||
tgts = [ | ||
rule_runner.get_target(Address("", target_name="t", relative_file_path="good.yaml")), | ||
rule_runner.get_target(Address("", target_name="t", relative_file_path="bad.yaml")), | ||
] | ||
result = run_spectral(rule_runner, tgts) | ||
assert len(result) == 1 | ||
assert result[0].exit_code == 1 | ||
|
||
|
||
def test_passthrough_args(rule_runner: RuleRunner) -> None: | ||
rule_runner.write_files({"openapi.yaml": GOOD_FILE, "BUILD": "openapi_documents(name='t')"}) | ||
tgt = rule_runner.get_target(Address("", target_name="t", relative_file_path="openapi.yaml")) | ||
result = run_spectral(rule_runner, [tgt], extra_args=["--spectral-args='--fail-severity=warn'"]) | ||
assert len(result) == 1 | ||
assert result[0].exit_code == 1 | ||
assert "openapi.yaml" in result[0].stdout | ||
|
||
|
||
def test_skip(rule_runner: RuleRunner) -> None: | ||
rule_runner.write_files({"openapi.yaml": BAD_FILE, "BUILD": "openapi_documents(name='t')"}) | ||
tgt = rule_runner.get_target(Address("", target_name="t", relative_file_path="openapi.yaml")) | ||
result = run_spectral(rule_runner, [tgt], extra_args=["--spectral-skip"]) | ||
assert not result | ||
|
||
|
||
def test_dependencies(rule_runner: RuleRunner) -> None: | ||
rule_runner.write_files( | ||
{ | ||
"openapi.yaml": dedent( | ||
"""\ | ||
openapi: 3.0.0 | ||
info: | ||
title: Example | ||
version: 1.0.0 | ||
paths: | ||
/example: | ||
$ref: 'example.yaml' | ||
""" | ||
), | ||
"example.yaml": "{}", | ||
"BUILD": "openapi_documents(name='t')\nopenapi_sources(name='sources')", | ||
} | ||
) | ||
|
||
tgt = rule_runner.get_target(Address("", target_name="t", relative_file_path="openapi.yaml")) | ||
assert_success( | ||
rule_runner, | ||
tgt, | ||
) |
18 changes: 18 additions & 0 deletions
18
src/python/pants/backend/openapi/lint/spectral/skip_field.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
from pants.backend.openapi.target_types import OpenApiDocumentGeneratorTarget, OpenApiDocumentTarget | ||
from pants.engine.target import BoolField | ||
|
||
|
||
class SkipSpectralField(BoolField): | ||
alias = "skip_spectral" | ||
default = False | ||
help = "If true, don't run `spectral lint` on this target's code." | ||
|
||
|
||
def rules(): | ||
return [ | ||
OpenApiDocumentTarget.register_plugin_field(SkipSpectralField), | ||
OpenApiDocumentGeneratorTarget.register_plugin_field(SkipSpectralField), | ||
] |
33 changes: 33 additions & 0 deletions
33
src/python/pants/backend/openapi/lint/spectral/subsystem.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
from __future__ import annotations | ||
|
||
from pants.core.util_rules.external_tool import TemplatedExternalTool | ||
from pants.option.option_types import ArgsListOption, SkipOption | ||
|
||
|
||
class SpectralSubsystem(TemplatedExternalTool): | ||
options_scope = "spectral" | ||
name = "Spectral" | ||
help = "A flexible JSON/YAML linter for creating automated style guides (https://github.com/stoplightio/spectral)." | ||
|
||
default_version = "v6.5.1" | ||
default_known_versions = [ | ||
"v6.5.1|linux_arm64 |81017af87e04711ab0a0a7c15af9985241f6c84101d039775057bbec17572916|72709153", | ||
"v6.5.1|linux_x86_64|81017af87e04711ab0a0a7c15af9985241f6c84101d039775057bbec17572916|72709153", | ||
"v6.5.1|macos_arm64 |5b10d772cb309d82b6a49b689ed58580dcb3393ead82b82ab648eead7da4bd79|77446257", | ||
"v6.5.1|macos_x86_64|5b10d772cb309d82b6a49b689ed58580dcb3393ead82b82ab648eead7da4bd79|77446257", | ||
] | ||
default_url_template = ( | ||
"https://github.com/stoplightio/spectral/releases/download/{version}/spectral-{platform}" | ||
) | ||
default_url_platform_mapping = { | ||
"macos_arm64": "macos", | ||
"macos_x86_64": "macos", | ||
"linux_arm64": "linux", | ||
"linux_x86_64": "linux", | ||
} | ||
|
||
skip = SkipOption("lint") | ||
args = ArgsListOption(example="--fail-severity=warn") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters