Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -780,3 +780,13 @@ openapi-validate:
vacuum lint -a -d -e <(curl http://localhost:8082/api-internal/swagger-ui/gundeck-swagger.json)
vacuum lint -a -d -e <(curl http://localhost:8082/api-internal/swagger-ui/brig-swagger.json)
vacuum lint -a -d -e <(curl http://localhost:8082/api-internal/swagger-ui/galley-swagger.json)

BRIG_URL ?= http://localhost:8082

# Usage: make openapi-lint-regression [VERSION=<n>] [BRIG_URL=<brig public url>] [UPDATE=1]
.PHONY: openapi-lint-regression
openapi-lint-regression:
@if [ -n "$(VERSION)" ] && ! echo "$(VERSION)" | grep -qE '^[0-9]+$$'; then \
echo "VERSION must be a positive integer" >&2; exit 1; \
fi
$(if $(VERSION),cabal run lint-openapi-regression -- --baseline-dir services/brig/docs --brig-url $(BRIG_URL) --api-version $(VERSION) $(if $(wildcard .lint-ignore/v$(VERSION).json),--ignore .lint-ignore/v$(VERSION).json,) $(if $(UPDATE),--update,),cabal run lint-openapi-regression -- --baseline-dir services/brig/docs --brig-url $(BRIG_URL) $(if $(UPDATE),--update,))
1 change: 1 addition & 0 deletions cabal.project
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ packages:
, tools/mlsstats/
, tools/test-stats/
, tools/entreprise-provisioning/
, tools/lint-openapi-regression/

tests: True
benchmarks: True
Expand Down
1 change: 1 addition & 0 deletions changelog.d/5-internal/WPB-26627-swagger-regression-test
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
wire-api test suite now enforces the frozen OpenAPI baselines (v5-v18) against the live-rendered spec; per-version ignore files under libs/wire-api/test/unit/ignore/ record already-accepted drift
4 changes: 3 additions & 1 deletion docs/src/developer/developer/api-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ the version. In these example we assume that version `V6` should be finalized an
new version to the end.
- In the same `Version` module update the `isDevelopmentVersion` to your taste.
- In `services/brig/src/Brig/API/Public.hs`
- update `versionedSwaggerDocsAPI` so that the finalized version points to the pregenerated swagger, and the dynamically generated swagger spits out swagger for the new `V7`.
- update `versionedSwaggerDocsAPI` so that the finalized version is
live-rendered via `liveSwagger (Proxy @'V7) (VersionNumber V7)`, and add a
new branch for the development version (`V8`) using `devVersionSwagger`.
- Set the version for `gDefaultAPIVersion` in `integration/test/Testlib/Env.hs` to 7.
- Consider updating the `backendApiVersion` value in Stern, which is
unit-tested by checking if it is listed as supported in the response to `GET /api-version`.
Expand Down
3 changes: 3 additions & 0 deletions libs/wire-api/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
, kan-extensions
, lens
, lib
, lint-openapi-regression
, metrics-wai
, mime
, mtl
Expand Down Expand Up @@ -271,6 +272,7 @@ mkDerivation {
iso3166-country-codes
iso639
lens
lint-openapi-regression
metrics-wai
openapi3
process
Expand All @@ -282,6 +284,7 @@ mkDerivation {
saml2-web-sso
schema-profunctor
servant
servant-openapi3
servant-server
string-conversions
tasty
Expand Down
75 changes: 57 additions & 18 deletions libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,24 @@
-- You should have received a copy of the GNU Affero General Public License along
-- with this program. If not, see <https://www.gnu.org/licenses/>.

-- | The swagger docs for the public API of the development version.
--
-- Older versions are frozen and served from the pregenerated JSON files in
-- @services/brig/docs/@; this is the only version that is still assembled from
-- the routing tables. It lives here rather than in brig so that tests in this
-- package can get at it -- see @Test.Wire.API.Routes.OAuthScopes@.
-- | Live-rendered OpenAPI of the public API for a given version, assembled
-- from the routing tables. It lives here rather than in brig so that tests in
-- this package can get at it -- see @Test.Wire.API.Routes.OAuthScopes@.
module Wire.API.Routes.Public.Swagger
( devVersion,
( allServicesSwagger,
devVersion,
devVersionSwagger,
versionedSwagger,
)
where

import Control.Lens ((.~))
import Data.OpenApi qualified as S
import Data.Proxy (Proxy (..))
import Imports
import Servant.API (toUrlPiece)
import Wire.API.Routes.API (serviceSwagger)
import Servant.OpenApi (HasOpenApi)
import Wire.API.Routes.API (SpecialisedAPIRoutes, serviceSwagger)
import Wire.API.Routes.Public.Brig (BrigAPITag)
import Wire.API.Routes.Public.Brig.OAuth (OAuthAPITag)
import Wire.API.Routes.Public.Cannon (CannonAPITag)
Expand All @@ -54,22 +55,60 @@ devVersion =
-- if you get this error, you also need to update the version literals below.
error "libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs#devVersion: please update to latest api version!"

-- | Live-rendered OpenAPI of all public services restricted to version @v@.
allServicesSwagger ::
forall v.
( HasOpenApi (SpecialisedAPIRoutes v BrigAPITag),
HasOpenApi (SpecialisedAPIRoutes v GalleyAPITag),
HasOpenApi (SpecialisedAPIRoutes v SparAPITag),
HasOpenApi (SpecialisedAPIRoutes v CargoholdAPITag),
HasOpenApi (SpecialisedAPIRoutes v CannonAPITag),
HasOpenApi (SpecialisedAPIRoutes v GundeckAPITag),
HasOpenApi (SpecialisedAPIRoutes v OAuthAPITag)
) =>
S.OpenApi
allServicesSwagger =
serviceSwagger @VersionAPITag @v
<> serviceSwagger @BrigAPITag @v
<> serviceSwagger @GalleyAPITag @v
<> serviceSwagger @SparAPITag @v
<> serviceSwagger @CargoholdAPITag @v
<> serviceSwagger @CannonAPITag @v
<> serviceSwagger @GundeckAPITag @v
<> serviceSwagger @ProxyAPITag @v
<> serviceSwagger @OAuthAPITag @v

-- | Note that brig additionally sets @info.description@ from
-- @services/brig/docs/swagger.md@, which cannot move here: it is embedded
-- relative to the brig package. 'cleanupSwagger' does not touch
-- @info.description@, so setting it afterwards is equivalent.
devVersionSwagger :: S.OpenApi
devVersionSwagger =
( serviceSwagger @VersionAPITag @'V19
<> serviceSwagger @BrigAPITag @'V19
<> serviceSwagger @GalleyAPITag @'V19
<> serviceSwagger @SparAPITag @'V19
<> serviceSwagger @CargoholdAPITag @'V19
<> serviceSwagger @CannonAPITag @'V19
<> serviceSwagger @GundeckAPITag @'V19
<> serviceSwagger @ProxyAPITag @'V19
<> serviceSwagger @OAuthAPITag @'V19
)
(allServicesSwagger @'V19)
& S.info . S.title .~ "Wire-Server API"
& S.servers .~ [S.Server ("/" <> toUrlPiece devVersion) Nothing mempty]
& cleanupSwagger

-- | Live-rendered OpenAPI of all public services restricted to version @v@,
-- adjusted like brig's frozen snapshots used to be: server URL set to @/vN@,
-- title set, and servant's default responses cleaned up. This is the shared
-- core of brig's @liveSwagger@ and the frozen-baseline regression test in
-- @Test.Wire.API.Routes.SwaggerRegression@.
versionedSwagger ::
forall v.
( HasOpenApi (SpecialisedAPIRoutes v BrigAPITag),
HasOpenApi (SpecialisedAPIRoutes v GalleyAPITag),
HasOpenApi (SpecialisedAPIRoutes v SparAPITag),
HasOpenApi (SpecialisedAPIRoutes v CargoholdAPITag),
HasOpenApi (SpecialisedAPIRoutes v CannonAPITag),
HasOpenApi (SpecialisedAPIRoutes v GundeckAPITag),
HasOpenApi (SpecialisedAPIRoutes v OAuthAPITag)
) =>
Proxy v ->
VersionNumber ->
S.OpenApi
versionedSwagger _ vn =
allServicesSwagger @v
& S.info . S.title .~ "Wire-Server API"
& S.servers .~ [S.Server ("/" <> toUrlPiece vn) Nothing mempty]
& cleanupSwagger
149 changes: 149 additions & 0 deletions libs/wire-api/test/unit/Test/Wire/API/Routes/SwaggerRegression.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
{-# LANGUAGE TemplateHaskell #-}

-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2025 Wire Swiss GmbH <opensource@wire.com>
--
-- This program is free software: you can redistribute it and/or modify it under
-- the terms of the GNU Affero General Public License as published by the Free
-- Software Foundation, either version 3 of the License, or (at your option) any
-- later version.
--
-- This program is distributed in the hope that it will be useful, but WITHOUT
-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
-- details.
--
-- You should have received a copy of the GNU Affero General Public License along
-- with this program. If not, see <https://www.gnu.org/licenses/>.

-- | CI enforcement of the OpenAPI baseline regression check: the live-rendered
-- spec of every frozen API version (v5..v18) must remain backward compatible
-- with its committed baseline @services/brig/docs/swagger-v*.json@.
--
-- Frozen baselines of shipped versions never change; when this test fails, fix
-- the rendering change (or its accidental introduction) instead of editing the
-- baseline. Violations on routes already accepted as drifted are suppressed
-- by the per-version ignore files in @test\/unit\/ignore\/@ (JSON arrays of
-- route identifiers, @LintOpenAPI.Ignore@ format); a violation on a route not
-- in the ignore file fails the test. This is the compile-time twin of
-- @make openapi-lint-regression@, which additionally checks the live-rendered
-- spec of a running brig.
module Test.Wire.API.Routes.SwaggerRegression
( tests,
)
where

import Data.Aeson qualified as A
import Data.FileEmbed (makeRelativeToProject)
import Data.Proxy (Proxy (..))
import Imports
import Language.Haskell.TH (litE, runIO, stringL)
import LintOpenAPI.Compare qualified as LintOpenAPI
import LintOpenAPI.Ignore qualified as LintOpenAPI
import LintOpenAPI.Parse qualified as LintOpenAPI
import Servant.OpenApi (HasOpenApi)
import System.FilePath ((</>))
import Test.Tasty
import Test.Tasty.HUnit
import Wire.API.Routes.API (SpecialisedAPIRoutes)
import Wire.API.Routes.Public.Brig (BrigAPITag)
import Wire.API.Routes.Public.Brig.OAuth (OAuthAPITag)
import Wire.API.Routes.Public.Cannon (CannonAPITag)
import Wire.API.Routes.Public.Cargohold (CargoholdAPITag)
import Wire.API.Routes.Public.Galley (GalleyAPITag)
import Wire.API.Routes.Public.Gundeck (GundeckAPITag)
import Wire.API.Routes.Public.Spar (SparAPITag)
import Wire.API.Routes.Public.Swagger (versionedSwagger)
import Wire.API.Routes.Version

-- | @services\/brig\/docs\/swagger-v*.json@, the frozen OpenAPI baselines.
--
-- Under nix only this package's own directory is copied into the build sandbox,
-- so @nix\/wire-server.nix@ splices the baselines into
-- @test\/unit\/generated\/baselines@ and we prefer that copy; a plain cabal
-- build has the whole repository checked out and reads the real files instead.
-- The lookup is inline because a top-level splice cannot call a function
-- defined in the same module.
baselineDir :: FilePath
baselineDir =
$( do
spliced <- makeRelativeToProject "test/unit/generated/baselines"
spliced' <- runIO (doesDirectoryExist spliced)
fp <-
if spliced'
then pure spliced
else makeRelativeToProject "../../services/brig/docs"
litE (stringL fp)
)

-- | Per-version ignore files (@swagger-ignore-v*.json@), suppressing violations
-- on routes already accepted as drifted. These live in this package's test
-- tree (unlike the baselines, which belong to brig), so they are present both
-- in a plain checkout and inside the nix build sandbox without splicing.
ignoreDir :: FilePath
ignoreDir =
$(litE . stringL =<< makeRelativeToProject "test/unit/ignore")

-- | Frozen, OpenAPI-3 baselines only: v0..v4 are Swagger 2.0, which the linter
-- does not support (@LintOpenAPI.Baseline.isOpenAPI3File@); v19 is the dev
-- version and has no baseline.
tests :: TestTree
tests =
testGroup
"OpenAPI regression (frozen baselines vs. live rendering)"
[ checkVersion 5 (Proxy @'V5) (VersionNumber V5),
checkVersion 6 (Proxy @'V6) (VersionNumber V6),
checkVersion 7 (Proxy @'V7) (VersionNumber V7),
checkVersion 8 (Proxy @'V8) (VersionNumber V8),
checkVersion 9 (Proxy @'V9) (VersionNumber V9),
checkVersion 10 (Proxy @'V10) (VersionNumber V10),
checkVersion 11 (Proxy @'V11) (VersionNumber V11),
checkVersion 12 (Proxy @'V12) (VersionNumber V12),
checkVersion 13 (Proxy @'V13) (VersionNumber V13),
checkVersion 14 (Proxy @'V14) (VersionNumber V14),
checkVersion 15 (Proxy @'V15) (VersionNumber V15),
checkVersion 16 (Proxy @'V16) (VersionNumber V16),
checkVersion 17 (Proxy @'V17) (VersionNumber V17),
checkVersion 18 (Proxy @'V18) (VersionNumber V18)
]

checkVersion ::
forall v.
( HasOpenApi (SpecialisedAPIRoutes v BrigAPITag),
HasOpenApi (SpecialisedAPIRoutes v GalleyAPITag),
HasOpenApi (SpecialisedAPIRoutes v SparAPITag),
HasOpenApi (SpecialisedAPIRoutes v CargoholdAPITag),
HasOpenApi (SpecialisedAPIRoutes v CannonAPITag),
HasOpenApi (SpecialisedAPIRoutes v GundeckAPITag),
HasOpenApi (SpecialisedAPIRoutes v OAuthAPITag)
) =>
Int ->
Proxy v ->
VersionNumber ->
TestTree
checkVersion n pv vn = testCase ("v" <> show n) $ do
baseline <-
LintOpenAPI.parseOpenAPIFile (baselineDir </> ("swagger-v" <> show n <> ".json"))
>>= either (assertFailure . ("parsing baseline: " <>)) pure
candidate <-
either assertFailure pure $
LintOpenAPI.parseOpenAPIValue (A.toJSON (versionedSwagger pv vn))
let violations = LintOpenAPI.compareSpecs baseline candidate
ignored <-
LintOpenAPI.readIgnoreFile (ignoreDir </> ("swagger-ignore-v" <> show n <> ".json"))
>>= either assertFailure pure
let unignored = filter (not . LintOpenAPI.isIgnored ignored) violations
ignoredCount = length violations - length unignored
unless (null unignored) $
assertFailure $
"live rendering of v"
<> show n
<> " is not backward compatible with the frozen baseline: "
<> show unignored
<> " (suppressing "
<> show ignoredCount
<> " already-ignored violations)"
<> "\nFrozen baselines of shipped versions never change; fix the rendering change instead of editing the baseline."
<> "\nIf (and only if) the drift is an accepted product decision, add the violating routes to "
<> ("test/unit/ignore/swagger-ignore-v" <> show n <> ".json")
2 changes: 2 additions & 0 deletions libs/wire-api/test/unit/Test/Wire/API/Run.hs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import Test.Wire.API.Roundtrip.MLS qualified as Roundtrip.MLS
import Test.Wire.API.Roundtrip.PostgresMarshall as PostgresMarshall
import Test.Wire.API.Routes qualified as Routes
import Test.Wire.API.Routes.OAuthScopes qualified as Routes.OAuthScopes
import Test.Wire.API.Routes.SwaggerRegression qualified as Routes.SwaggerRegression
import Test.Wire.API.Routes.Version qualified as Routes.Version
import Test.Wire.API.Routes.Version.Wai qualified as Routes.Version.Wai
import Test.Wire.API.Swagger qualified as Swagger
Expand Down Expand Up @@ -65,6 +66,7 @@ main =
Roundtrip.CSV.tests,
Routes.tests,
Routes.OAuthScopes.tests,
Routes.SwaggerRegression.tests,
Conversation.tests,
Meeting.tests,
MLS.tests,
Expand Down
20 changes: 20 additions & 0 deletions libs/wire-api/test/unit/ignore/swagger-ignore-v10.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[
"add-bot",
"add-members-to-conversation",
"create-conversation-code-unqualified",
"create-group-conversation",
"join-conversation-by-code-unqualified",
"mls-commit-bundle",
"mls-message",
"register",
"remove-bot",
"remove-code-unqualified",
"remove-member",
"update-channel-add-permission",
"update-client",
"update-conversation-access",
"update-conversation-message-timer",
"update-conversation-name",
"update-conversation-protocol",
"update-conversation-receipt-mode"
]
20 changes: 20 additions & 0 deletions libs/wire-api/test/unit/ignore/swagger-ignore-v11.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[
"add-bot",
"add-members-to-conversation",
"create-conversation-code-unqualified",
"create-group-conversation",
"join-conversation-by-code-unqualified",
"mls-commit-bundle",
"mls-message",
"register",
"remove-bot",
"remove-code-unqualified",
"remove-member",
"update-channel-add-permission",
"update-client",
"update-conversation-access",
"update-conversation-message-timer",
"update-conversation-name",
"update-conversation-protocol",
"update-conversation-receipt-mode"
]
25 changes: 25 additions & 0 deletions libs/wire-api/test/unit/ignore/swagger-ignore-v12.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[
"add-bot",
"add-members-to-conversation",
"check-user-group-name-available",
"create-app",
"create-conversation-code-unqualified",
"create-group-conversation",
"join-conversation-by-code-unqualified",
"mls-commit-bundle",
"mls-message",
"register",
"remove-bot",
"remove-code-unqualified",
"remove-member",
"search-channels",
"update-channel-add-permission",
"update-client",
"update-conversation-access",
"update-conversation-message-timer",
"update-conversation-name",
"update-conversation-protocol",
"update-conversation-receipt-mode",
"update-user-group-channels",
"update-user-group-members"
]
Loading
Loading