From 3bf044d86526b2498058d28ad7942685b35eb8b7 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Sat, 27 Jun 2026 00:34:42 +0200 Subject: [PATCH 01/10] WPB-26627: introduce `lint-openapi-regression` --- .lint-ignore.json | 335 +++++++++ cabal.project | 1 + nix/local-haskell-packages.nix | 1 + tools/lint-openapi-regression/LICENSE | 661 ++++++++++++++++++ tools/lint-openapi-regression/README.md | 97 +++ tools/lint-openapi-regression/default.nix | 46 ++ tools/lint-openapi-regression/exe/Main.hs | 153 ++++ .../lint-openapi-regression.cabal | 218 ++++++ .../src/LintOpenAPI/Compare.hs | 130 ++++ .../src/LintOpenAPI/Ignore.hs | 70 ++ .../src/LintOpenAPI/Normalize.hs | 45 ++ .../src/LintOpenAPI/Parse.hs | 242 +++++++ .../src/LintOpenAPI/Report.hs | 108 +++ .../src/LintOpenAPI/Types.hs | 101 +++ .../test/LintOpenAPI/CompareSpec.hs | 199 ++++++ .../test/LintOpenAPI/IgnoreSpec.hs | 57 ++ .../test/LintOpenAPI/NormalizeSpec.hs | 59 ++ .../test/LintOpenAPI/ParseSpec.hs | 275 ++++++++ tools/lint-openapi-regression/test/Spec.hs | 1 + 19 files changed, 2799 insertions(+) create mode 100644 .lint-ignore.json create mode 100644 tools/lint-openapi-regression/LICENSE create mode 100644 tools/lint-openapi-regression/README.md create mode 100644 tools/lint-openapi-regression/default.nix create mode 100644 tools/lint-openapi-regression/exe/Main.hs create mode 100644 tools/lint-openapi-regression/lint-openapi-regression.cabal create mode 100644 tools/lint-openapi-regression/src/LintOpenAPI/Compare.hs create mode 100644 tools/lint-openapi-regression/src/LintOpenAPI/Ignore.hs create mode 100644 tools/lint-openapi-regression/src/LintOpenAPI/Normalize.hs create mode 100644 tools/lint-openapi-regression/src/LintOpenAPI/Parse.hs create mode 100644 tools/lint-openapi-regression/src/LintOpenAPI/Report.hs create mode 100644 tools/lint-openapi-regression/src/LintOpenAPI/Types.hs create mode 100644 tools/lint-openapi-regression/test/LintOpenAPI/CompareSpec.hs create mode 100644 tools/lint-openapi-regression/test/LintOpenAPI/IgnoreSpec.hs create mode 100644 tools/lint-openapi-regression/test/LintOpenAPI/NormalizeSpec.hs create mode 100644 tools/lint-openapi-regression/test/LintOpenAPI/ParseSpec.hs create mode 100644 tools/lint-openapi-regression/test/Spec.hs diff --git a/.lint-ignore.json b/.lint-ignore.json new file mode 100644 index 00000000000..33742201b56 --- /dev/null +++ b/.lint-ignore.json @@ -0,0 +1,335 @@ +{ + "v10": [ + "add-bot", + "add-members-to-conversation", + "create-conversation-code-unqualified", + "create-group-conversation", + "create-one-to-one-conversation", + "create-self-conversation", + "get-conversation", + "get-mls-self-conversation", + "get-one-to-one-mls-conversation", + "get-user-clients-qualified", + "join-conversation-by-code-unqualified", + "mls-commit-bundle", + "mls-message", + "put_CellsConfig", + "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" + ], + "v11": [ + "add-bot", + "add-members-to-conversation", + "create-conversation-code-unqualified", + "create-group-conversation", + "create-one-to-one-conversation", + "create-self-conversation", + "get-conversation", + "get-mls-self-conversation", + "get-one-to-one-mls-conversation", + "get-user-clients-qualified", + "join-conversation-by-code-unqualified", + "mls-commit-bundle", + "mls-message", + "put_CellsConfig", + "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" + ], + "v12": [ + "add-bot", + "add-members-to-conversation", + "create-app", + "create-conversation-code-unqualified", + "create-group-conversation", + "create-one-to-one-conversation", + "create-self-conversation", + "get-conversation", + "get-mls-self-conversation", + "get-one-to-one-mls-conversation", + "get-user-clients-qualified", + "join-conversation-by-code-unqualified", + "mls-commit-bundle", + "mls-message", + "put_CellsConfig", + "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" + ], + "v13": [ + "add-bot", + "add-members-to-conversation", + "create-app", + "create-conversation-code-unqualified", + "create-group-conversation", + "create-one-to-one-conversation", + "create-self-conversation", + "get-conversation", + "get-mls-self-conversation", + "get-one-to-one-mls-conversation", + "get-user-clients-qualified", + "join-conversation-by-code-unqualified", + "put_CellsConfig", + "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" + ], + "v14": [ + "add-bot", + "add-members-to-conversation", + "create-app", + "create-conversation-code-unqualified", + "create-group-conversation", + "create-one-to-one-conversation", + "create-self-conversation", + "get-app", + "get-conversation", + "get-mls-self-conversation", + "get-one-to-one-mls-conversation", + "get-user-clients-qualified", + "join-conversation-by-code-unqualified", + "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" + ], + "v15": [ + "get-app", + "get-user-clients-qualified" + ], + "v5": [ + "DELETE /conversations/{_}/bots/{_}", + "DELETE /conversations/{_}/code", + "DELETE /conversations/{_}/{_}/members/{_}/{_}", + "DELETE /oauth/applications/{_}", + "DELETE /self/phone", + "GET /", + "GET /bot/client", + "GET /calls/config", + "GET /clients/{_}", + "GET /clients/{_}/capabilities", + "GET /conversations/one2one/{_}/{_}", + "GET /conversations/{_}/self", + "GET /providers/{_}/services/{_}", + "GET /sso/initiate-login/{_}", + "GET /users/{_}/{_}/clients", + "HEAD /users/handles/{_}", + "POST /activate/send", + "POST /assets", + "POST /bot/assets", + "POST /bot/messages", + "POST /bot/users/prekeys", + "POST /broadcast/otr/messages", + "POST /broadcast/proteus/messages", + "POST /clients", + "POST /connections/{_}/{_}", + "POST /conversations", + "POST /conversations/join", + "POST /conversations/one2one", + "POST /conversations/{_}/bots", + "POST /conversations/{_}/code", + "POST /conversations/{_}/otr/messages", + "POST /conversations/{_}/{_}/members", + "POST /conversations/{_}/{_}/proteus/messages", + "POST /login/send", + "POST /mls/commit-bundles", + "POST /mls/messages", + "POST /onboarding/v3", + "POST /password-reset/{_}", + "POST /provider/assets", + "POST /push/tokens", + "POST /register", + "POST /teams/{_}/invitations", + "POST /teams/{_}/legalhold/{_}", + "POST /users/handles", + "PUT /clients/{_}", + "PUT /connections/{_}/{_}", + "PUT /conversations/{_}", + "PUT /conversations/{_}/members/{_}", + "PUT /conversations/{_}/message-timer", + "PUT /conversations/{_}/name", + "PUT /conversations/{_}/receipt-mode", + "PUT /conversations/{_}/self", + "PUT /conversations/{_}/{_}/access", + "PUT /conversations/{_}/{_}/message-timer", + "PUT /conversations/{_}/{_}/name", + "PUT /conversations/{_}/{_}/protocol", + "PUT /conversations/{_}/{_}/receipt-mode", + "PUT /self/phone" + ], + "v6": [ + "DELETE /conversations/{_}/bots/{_}", + "DELETE /conversations/{_}/code", + "DELETE /conversations/{_}/{_}/members/{_}/{_}", + "DELETE /oauth/applications/{_}", + "GET /", + "GET /bot/client", + "GET /calls/config", + "GET /clients/{_}", + "GET /clients/{_}/capabilities", + "GET /conversations/one2one/{_}/{_}", + "GET /conversations/{_}/self", + "GET /providers/{_}/services/{_}", + "GET /sso/initiate-login/{_}", + "GET /users/{_}/{_}/clients", + "HEAD /users/handles/{_}", + "POST /assets", + "POST /bot/assets", + "POST /clients", + "POST /conversations", + "POST /conversations/join", + "POST /conversations/one2one", + "POST /conversations/{_}/bots", + "POST /conversations/{_}/code", + "POST /conversations/{_}/{_}/members", + "POST /mls/commit-bundles", + "POST /mls/messages", + "POST /onboarding/v3", + "POST /password-reset/{_}", + "POST /provider/assets", + "POST /register", + "POST /teams/{_}/invitations", + "POST /teams/{_}/legalhold/{_}", + "POST /users/handles", + "PUT /clients/{_}", + "PUT /conversations/{_}", + "PUT /conversations/{_}/members/{_}", + "PUT /conversations/{_}/message-timer", + "PUT /conversations/{_}/name", + "PUT /conversations/{_}/receipt-mode", + "PUT /conversations/{_}/self", + "PUT /conversations/{_}/{_}/access", + "PUT /conversations/{_}/{_}/message-timer", + "PUT /conversations/{_}/{_}/name", + "PUT /conversations/{_}/{_}/protocol", + "PUT /conversations/{_}/{_}/receipt-mode" + ], + "v7": [ + "add-bot", + "add-members-to-conversation", + "assets-upload", + "assets-upload-v3_bot", + "assets-upload-v3_provider", + "auth-req", + "create-conversation-code-unqualified", + "create-group-conversation", + "get-calls-config", + "get-conversation-self-unqualified", + "get-user-clients-qualified", + "join-conversation-by-code-unqualified", + "mls-commit-bundle", + "mls-message", + "onboarding", + "post-password-reset-key-deprecated", + "register", + "remove-bot", + "remove-code-unqualified", + "remove-member", + "update-client@v7", + "update-conversation-access", + "update-conversation-message-timer", + "update-conversation-message-timer-unqualified", + "update-conversation-name", + "update-conversation-name-deprecated", + "update-conversation-name-unqualified", + "update-conversation-protocol", + "update-conversation-receipt-mode", + "update-conversation-receipt-mode-unqualified", + "update-conversation-self-unqualified" + ], + "v8": [ + "add-bot", + "add-members-to-conversation", + "auth-req", + "create-conversation-code-unqualified", + "create-group-conversation", + "create-one-to-one-conversation", + "create-self-conversation", + "get-conversation", + "get-domain-registration", + "get-mls-self-conversation", + "get-one-to-one-mls-conversation", + "get-user-clients-qualified", + "join-conversation-by-code-unqualified", + "mls-commit-bundle", + "mls-message", + "put_CellsConfig", + "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", + "update-domain-redirect" + ], + "v9": [ + "add-bot", + "add-members-to-conversation", + "create-conversation-code-unqualified", + "create-group-conversation@v9", + "create-one-to-one-conversation", + "create-self-conversation", + "get-conversation", + "get-domain-registration@v9", + "get-mls-self-conversation", + "get-one-to-one-mls-conversation", + "get-user-clients-qualified", + "join-conversation-by-code-unqualified", + "mls-commit-bundle", + "mls-message", + "put_CellsConfig", + "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", + "update-domain-redirect@v9" + ] +} \ No newline at end of file diff --git a/cabal.project b/cabal.project index 345b1d52076..53f01a6de9b 100644 --- a/cabal.project +++ b/cabal.project @@ -61,6 +61,7 @@ packages: , tools/mlsstats/ , tools/test-stats/ , tools/entreprise-provisioning/ + , tools/lint-openapi-regression/ tests: True benchmarks: True diff --git a/nix/local-haskell-packages.nix b/nix/local-haskell-packages.nix index 5562225f673..d65af06bc50 100644 --- a/nix/local-haskell-packages.nix +++ b/nix/local-haskell-packages.nix @@ -57,6 +57,7 @@ hsuper: hself: { service-backfill = hself.callPackage ../tools/db/service-backfill/default.nix { }; team-info = hself.callPackage ../tools/db/team-info/default.nix { }; entreprise-provisioning = hself.callPackage ../tools/entreprise-provisioning/default.nix { }; + lint-openapi-regression = hself.callPackage ../tools/lint-openapi-regression/default.nix { }; mlsstats = hself.callPackage ../tools/mlsstats/default.nix { }; rabbitmq-consumer = hself.callPackage ../tools/rabbitmq-consumer/default.nix { }; stern = hself.callPackage ../tools/stern/default.nix { }; diff --git a/tools/lint-openapi-regression/LICENSE b/tools/lint-openapi-regression/LICENSE new file mode 100644 index 00000000000..dba13ed2ddf --- /dev/null +++ b/tools/lint-openapi-regression/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + 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 . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/tools/lint-openapi-regression/README.md b/tools/lint-openapi-regression/README.md new file mode 100644 index 00000000000..2767c7972ee --- /dev/null +++ b/tools/lint-openapi-regression/README.md @@ -0,0 +1,97 @@ +# lint-openapi-regression + +Detects backward-incompatible changes in OpenAPI JSON files by comparing a +candidate spec against historical baseline versions (e.g. `swagger-v5.json`, +`swagger-v6.json`). + +## What it checks + +| Check | Breaking direction | +|------------------------------------|--------------------| +| Route removed | Always | +| Query parameter removed | Always | +| Required query parameter added | Always | +| Required body field added | Request | +| Response field removed (required) | Response | +| Enum value removed | Request | +| Enum value added | Response | + +## Building + +```bash +make c package=lint-openapi-regression +``` + +## Running tests + +```bash +make c package=lint-openapi-regression test=1 \ + | grep -vE 'Compiling|Linking|Preprocessing|Configuring|Building' +``` + +Or directly with Cabal: + +```bash +cabal test lint-openapi-regression-tests \ + | grep -vE 'Compiling|Linking|Preprocessing|Configuring|Building' +``` + +## CLI usage + +``` +lint-openapi-regression [OPTIONS] INPUT_FILE +``` + +### Options + +| Flag | Default | Description | +|--------------------|------------------------|------------------------------------------------------| +| `--baseline-dir` | `services/brig/docs` | Directory containing baseline `swagger-v*.json` files | +| `--ignore FILE` | (none) | Path to a JSON ignore file | +| `--update` | off | Update the ignore file with new breaking changes | + +### Examples + +Check a new spec against all baselines in the default directory: + +```bash +lint-openapi-regression services/brig/docs/swagger.json +``` + +Check against baselines in a custom directory: + +```bash +lint-openapi-regression --baseline-dir path/to/baselines new-spec.json +``` + +Use an ignore file to suppress known violations: + +```bash +lint-openapi-regression --ignore .lint-ignore.json services/brig/docs/swagger.json +``` + +Auto-update the ignore file with any new violations found: + +```bash +lint-openapi-regression --ignore .lint-ignore.json --update services/brig/docs/swagger.json +``` + +## Exit codes + +| Code | Meaning | +|------|--------------------------------------------| +| 0 | No breaking changes (or all are ignored) | +| 1 | Breaking changes detected | +| 2 | Input error (file not found, parse failure)| + +## Ignore file format + +The ignore file is a JSON object mapping baseline version keys to sets of route +identifiers (either `operationId` or rendered route like `GET /users/{_}`): + +```json +{ + "v5": ["getUser", "POST /teams"], + "v6": ["GET /users/{_}"] +} +``` diff --git a/tools/lint-openapi-regression/default.nix b/tools/lint-openapi-regression/default.nix new file mode 100644 index 00000000000..6da480ab6ad --- /dev/null +++ b/tools/lint-openapi-regression/default.nix @@ -0,0 +1,46 @@ +# WARNING: GENERATED FILE, DO NOT EDIT. +# This file is generated by running hack/bin/generate-local-nix-packages.sh and +# must be regenerated whenever local packages are added or removed, or +# dependencies are added or removed. +{ mkDerivation +, aeson +, aeson-pretty +, base +, bytestring +, containers +, filepath +, hspec +, imports +, lib +, optparse-applicative +, text +, vector +}: +mkDerivation { + pname = "lint-openapi-regression"; + version = "0.1.0"; + src = ./.; + isLibrary = true; + isExecutable = true; + libraryHaskellDepends = [ + aeson + aeson-pretty + bytestring + containers + imports + text + vector + ]; + executableHaskellDepends = [ + base + containers + filepath + imports + optparse-applicative + text + ]; + testHaskellDepends = [ aeson containers hspec imports text ]; + description = "OpenAPI backward-compatibility linter"; + license = lib.licenses.agpl3Only; + mainProgram = "lint-openapi-regression"; +} diff --git a/tools/lint-openapi-regression/exe/Main.hs b/tools/lint-openapi-regression/exe/Main.hs new file mode 100644 index 00000000000..3885e38875c --- /dev/null +++ b/tools/lint-openapi-regression/exe/Main.hs @@ -0,0 +1,153 @@ +module Main (main) where + +import Data.List qualified as List +import Data.Map.Strict qualified as Map +import Data.Text qualified as Text +import Data.Text.IO qualified as Text.IO +import Imports +import LintOpenAPI.Compare (compareSpecs) +import LintOpenAPI.Ignore (isIgnored, readIgnoreFile, updateIgnoreMap, writeIgnoreFile) +import LintOpenAPI.Parse (parseOpenAPIFile) +import LintOpenAPI.Report (formatViolations) +import Options.Applicative +import System.Exit (ExitCode (..), exitWith) +import System.FilePath (()) +import System.IO qualified as IO + +-- | CLI configuration. +data Options = Options + { baselineDir :: FilePath, + inputFile :: FilePath, + ignoreFile :: Maybe FilePath, + updateIgnore :: Bool + } + +-- | Parse CLI options. +optionsParser :: Parser Options +optionsParser = + Options + <$> strOption + ( long "baseline-dir" + <> metavar "DIR" + <> value "services/brig/docs" + <> showDefault + <> help "Directory containing baseline swagger-v*.json files" + ) + <*> strArgument + ( metavar "INPUT_FILE" + <> help "Path to the new OpenAPI JSON file to check" + ) + <*> optional + ( strOption + ( long "ignore" + <> metavar "FILE" + <> help "Path to the JSON ignore file" + ) + ) + <*> switch + ( long "update" + <> help "Update the ignore file with unignored breaking changes" + ) + +-- | Top-level CLI parser with help text. +optionsParserInfo :: ParserInfo Options +optionsParserInfo = + info + (optionsParser <**> helper) + ( fullDesc + <> progDesc "Check an OpenAPI JSON file for backward-incompatible changes" + <> header "lint-openapi-regression - OpenAPI backward-compatibility linter" + ) + +main :: IO () +main = do + opts <- execParser optionsParserInfo + result <- runLint opts + exitWith result + +-- | Main lint workflow. Returns appropriate exit code. +runLint :: Options -> IO ExitCode +runLint opts = do + -- Parse the input file + inputExists <- doesFileExist opts.inputFile + unless inputExists $ do + Text.IO.hPutStrLn IO.stderr $ "Error: Input file not found: " <> Text.pack opts.inputFile + exitWith (ExitFailure 2) + + inputResult <- parseOpenAPIFile opts.inputFile + candidate <- case inputResult of + Left err -> do + Text.IO.hPutStrLn IO.stderr $ "Error parsing input file: " <> Text.pack err + exitWith (ExitFailure 2) + Right spec -> pure spec + + -- Discover and parse baseline files + baselineFiles <- discoverBaselineFiles opts.baselineDir + when (null baselineFiles) $ do + Text.IO.hPutStrLn IO.stderr $ + "Warning: No baseline swagger-v*.json files found in " <> Text.pack opts.baselineDir + + baselines <- forM baselineFiles $ \fp -> do + result <- parseOpenAPIFile fp + case result of + Left err -> do + Text.IO.hPutStrLn IO.stderr $ "Warning: Failed to parse " <> Text.pack fp <> ": " <> Text.pack err + pure Nothing + Right spec -> pure (Just spec) + + -- Load ignore map if specified + ignoreMap <- case opts.ignoreFile of + Just f -> readIgnoreFile f + Nothing -> pure Map.empty + + let validBaselines = catMaybes baselines + allViolations = concatMap (`compareSpecs` candidate) validBaselines + (ignoredViolations, unignoredViolations) = List.partition (isIgnored ignoreMap) allViolations + report = formatViolations (length ignoredViolations) unignoredViolations + + Text.IO.hPutStrLn IO.stderr report + + when opts.updateIgnore $ do + case opts.ignoreFile of + Just f -> do + let newMap = updateIgnoreMap ignoreMap unignoredViolations + writeIgnoreFile f newMap + Text.IO.hPutStrLn IO.stderr $ "Updated ignore file: " <> Text.pack f + Nothing -> Text.IO.hPutStrLn IO.stderr "Warning: --update specified but no --ignore file provided." + + if null unignoredViolations + then pure ExitSuccess + else pure (ExitFailure 1) + +-- | Discover baseline swagger-v*.json files in a directory. +-- Only includes OpenAPI 3.0 files (v5+). +discoverBaselineFiles :: FilePath -> IO [FilePath] +discoverBaselineFiles dir = do + exists <- doesFileExist (dir "swagger-v5.json") + if not exists + then pure [] + else do + entries <- listDirectory dir + let swaggerFiles = + List.sort + [ dir e + | e <- entries, + "swagger-v" `List.isPrefixOf` e, + ".json" `List.isSuffixOf` e, + isOpenAPI3File e + ] + pure swaggerFiles + +-- | Check if a swagger filename is v5 or higher (OpenAPI 3.0). +isOpenAPI3File :: String -> Bool +isOpenAPI3File name = + case extractVersionNum name of + Just n -> n >= 5 + Nothing -> False + +-- | Extract the version number from a filename like "swagger-v5.json". +extractVersionNum :: String -> Maybe Int +extractVersionNum name = do + rest <- List.stripPrefix "swagger-v" name + let numStr = takeWhile (/= '.') rest + readMaybe numStr diff --git a/tools/lint-openapi-regression/lint-openapi-regression.cabal b/tools/lint-openapi-regression/lint-openapi-regression.cabal new file mode 100644 index 00000000000..065c5b77a10 --- /dev/null +++ b/tools/lint-openapi-regression/lint-openapi-regression.cabal @@ -0,0 +1,218 @@ +cabal-version: >=1.10 +name: lint-openapi-regression +version: 0.1.0 +synopsis: OpenAPI backward-compatibility linter +description: + Detects non-backward-compatible changes in OpenAPI JSON files + by comparing against historical baseline swagger versions. + +category: Tool +author: Wire Swiss GmbH +maintainer: Wire Swiss GmbH +license: AGPL-3 +license-file: LICENSE +build-type: Simple + +flag static + description: Enable static linking + default: False + +library + exposed-modules: + LintOpenAPI.Compare + LintOpenAPI.Ignore + LintOpenAPI.Normalize + LintOpenAPI.Parse + LintOpenAPI.Report + LintOpenAPI.Types + + hs-source-dirs: src + default-extensions: + AllowAmbiguousTypes + BangPatterns + ConstraintKinds + DataKinds + DefaultSignatures + DeriveFunctor + DeriveGeneric + DeriveLift + DeriveTraversable + DerivingStrategies + DerivingVia + DuplicateRecordFields + EmptyCase + FlexibleContexts + FlexibleInstances + FunctionalDependencies + GADTs + InstanceSigs + KindSignatures + LambdaCase + MultiParamTypeClasses + MultiWayIf + NamedFieldPuns + NoImplicitPrelude + OverloadedRecordDot + OverloadedStrings + PackageImports + PatternSynonyms + PolyKinds + QuasiQuotes + RankNTypes + ScopedTypeVariables + StandaloneDeriving + TupleSections + TypeApplications + TypeFamilies + TypeFamilyDependencies + TypeOperators + UndecidableInstances + ViewPatterns + + ghc-options: + -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates + -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path + -Wredundant-constraints -Wunused-packages + + build-depends: + aeson + , aeson-pretty + , bytestring + , containers + , imports + , text + , vector + + default-language: GHC2021 + +executable lint-openapi-regression + main-is: Main.hs + hs-source-dirs: exe + default-extensions: + AllowAmbiguousTypes + BangPatterns + ConstraintKinds + DataKinds + DefaultSignatures + DeriveFunctor + DeriveGeneric + DeriveLift + DeriveTraversable + DerivingStrategies + DerivingVia + DuplicateRecordFields + EmptyCase + FlexibleContexts + FlexibleInstances + FunctionalDependencies + GADTs + InstanceSigs + KindSignatures + LambdaCase + MultiParamTypeClasses + MultiWayIf + NamedFieldPuns + NoImplicitPrelude + OverloadedRecordDot + OverloadedStrings + PackageImports + PatternSynonyms + PolyKinds + QuasiQuotes + RankNTypes + ScopedTypeVariables + StandaloneDeriving + TupleSections + TypeApplications + TypeFamilies + TypeFamilyDependencies + TypeOperators + UndecidableInstances + ViewPatterns + + ghc-options: + -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates + -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path + -threaded -rtsopts -with-rtsopts=-T -Wredundant-constraints + -Wunused-packages + + build-depends: + base + , containers + , filepath + , imports + , lint-openapi-regression + , optparse-applicative + , text + + if flag(static) + ld-options: -static + + default-language: GHC2021 + +test-suite lint-openapi-regression-tests + type: exitcode-stdio-1.0 + main-is: Spec.hs + other-modules: + LintOpenAPI.CompareSpec + LintOpenAPI.IgnoreSpec + LintOpenAPI.NormalizeSpec + LintOpenAPI.ParseSpec + + hs-source-dirs: test + default-extensions: + AllowAmbiguousTypes + BangPatterns + ConstraintKinds + DataKinds + DefaultSignatures + DeriveFunctor + DeriveGeneric + DeriveLift + DeriveTraversable + DerivingStrategies + DerivingVia + DuplicateRecordFields + EmptyCase + FlexibleContexts + FlexibleInstances + FunctionalDependencies + GADTs + InstanceSigs + KindSignatures + LambdaCase + MultiParamTypeClasses + MultiWayIf + NamedFieldPuns + NoImplicitPrelude + OverloadedRecordDot + OverloadedStrings + PackageImports + PatternSynonyms + PolyKinds + QuasiQuotes + RankNTypes + ScopedTypeVariables + StandaloneDeriving + TupleSections + TypeApplications + TypeFamilies + TypeFamilyDependencies + TypeOperators + UndecidableInstances + ViewPatterns + + ghc-options: + -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates + -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path + -threaded -Wredundant-constraints -Wunused-packages + + build-depends: + aeson + , containers + , hspec + , imports + , lint-openapi-regression + , text + + default-language: GHC2021 diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Compare.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Compare.hs new file mode 100644 index 00000000000..6b4ac7dfc2e --- /dev/null +++ b/tools/lint-openapi-regression/src/LintOpenAPI/Compare.hs @@ -0,0 +1,130 @@ +module LintOpenAPI.Compare + ( compareSpecs, + compareRouteInfo, + ) +where + +import Data.Map.Strict qualified as Map +import Data.Set qualified as Set +import Imports +import LintOpenAPI.Types + +-- | Compare a candidate spec against a baseline spec. +-- Returns violations where the candidate breaks backward compatibility. +compareSpecs :: OpenAPISpec -> OpenAPISpec -> [ViolationContext] +compareSpecs baseline candidate = + concatMap (uncurry $ compareRoute baseline.version candidate.routes) (Map.toList baseline.routes) + +-- | Compare a single baseline route against the candidate route map. +compareRoute :: + Maybe Int -> + Map.Map RouteKey RouteInfo -> + RouteKey -> + RouteInfo -> + [ViolationContext] +compareRoute ver candidateRoutes rk baselineInfo = + case Map.lookup rk candidateRoutes of + Nothing -> + [mkCtx RouteRemoved] + Just candidateInfo -> + map mkCtx (compareRouteInfo baselineInfo candidateInfo) + where + mkCtx v = + ViolationContext + { baselineVersion = ver, + routeKey = rk, + routeId = baselineInfo.operationId, + violation = v + } + +-- | Compare route info for backward-incompatible changes. +compareRouteInfo :: RouteInfo -> RouteInfo -> [Violation] +compareRouteInfo baseline candidate = + compareQueryParams baseline candidate + <> compareRequestBody baseline candidate + <> compareResponseBodies baseline candidate + +-- | Check for removed query params and new required query params. +compareQueryParams :: RouteInfo -> RouteInfo -> [Violation] +compareQueryParams baseline candidate = + let removedParams = Set.difference baseline.queryParams candidate.queryParams + newRequiredParams = Set.difference candidate.requiredQueryParams baseline.queryParams + in map QueryParamRemoved (Set.toList removedParams) + <> map RequiredQueryParamAdded (Set.toList newRequiredParams) + +-- | Check for new required fields in the request body. +compareRequestBody :: RouteInfo -> RouteInfo -> [Violation] +compareRequestBody baseline candidate = + case (baseline.requestBody, candidate.requestBody) of + (_, Nothing) -> [] + (Nothing, Just candSchema) -> + -- Candidate added a body where none existed; only flag required fields + map RequiredBodyFieldAdded (Set.toList candSchema.requiredFields) + (Just baseSchema, Just candSchema) -> + compareRequestSchema baseSchema candSchema + +-- | Compare request schemas for new required fields and removed enum values. +compareRequestSchema :: ResolvedSchema -> ResolvedSchema -> [Violation] +compareRequestSchema baseline candidate = + let newRequired = Set.difference candidate.requiredFields baseline.requiredFields + enumViolations = compareRequestEnumValues baseline candidate + nestedViolations = compareNestedRequestSchemas baseline candidate + in map RequiredBodyFieldAdded (Set.toList newRequired) + <> enumViolations + <> nestedViolations + +-- | Compare response bodies for removed required fields. +compareResponseBodies :: RouteInfo -> RouteInfo -> [Violation] +compareResponseBodies baseline candidate = + concatMap checkResponse (Map.toList baseline.responses) + where + checkResponse (code, baseSchema) = + case Map.lookup code candidate.responses of + Nothing -> [] + Just candSchema -> compareResponseSchema baseSchema candSchema + +-- | Compare response schemas for removed fields and removed enum values. +compareResponseSchema :: ResolvedSchema -> ResolvedSchema -> [Violation] +compareResponseSchema baseline candidate = + let baseRequiredProps = Set.intersection baseline.requiredFields (Map.keysSet baseline.properties) + candProps = Map.keysSet candidate.properties + removedRequiredProps = Set.difference baseRequiredProps candProps + enumViolations = compareResponseEnumValues baseline candidate + nestedViolations = compareNestedResponseSchemas baseline candidate + in map ResponseFieldRemoved (Set.toList removedRequiredProps) + <> enumViolations + <> nestedViolations + +-- | In request schemas, removing an enum value is breaking (clients may send it). +compareRequestEnumValues :: ResolvedSchema -> ResolvedSchema -> [Violation] +compareRequestEnumValues baseline candidate = + case (baseline.enumValues, candidate.enumValues) of + (Just baseEnum, Just candEnum) -> + let removed = Set.difference baseEnum candEnum + in map (EnumValueRemoved "") (Set.toList removed) + _ -> [] + +-- | In response schemas, adding an enum value is breaking (clients may not handle it). +compareResponseEnumValues :: ResolvedSchema -> ResolvedSchema -> [Violation] +compareResponseEnumValues baseline candidate = + case (baseline.enumValues, candidate.enumValues) of + (Just baseEnum, Just candEnum) -> + let added = Set.difference candEnum baseEnum + in map (EnumValueAdded "") (Set.toList added) + _ -> [] + +-- | Recursively compare nested request schemas for properties that exist in both. +compareNestedRequestSchemas :: ResolvedSchema -> ResolvedSchema -> [Violation] +compareNestedRequestSchemas baseline candidate = + let commonProps = Map.intersectionWith (,) baseline.properties candidate.properties + in concatMap + (\(_, (baseProp, candProp)) -> compareRequestSchema baseProp candProp) + (Map.toList commonProps) + +-- | Recursively compare nested response schemas for properties that exist in both. +compareNestedResponseSchemas :: ResolvedSchema -> ResolvedSchema -> [Violation] +compareNestedResponseSchemas baseline candidate = + let commonProps = Map.intersectionWith (,) baseline.properties candidate.properties + in concatMap + (\(_, (baseProp, candProp)) -> compareResponseSchema baseProp candProp) + (Map.toList commonProps) diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Ignore.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Ignore.hs new file mode 100644 index 00000000000..5603592b969 --- /dev/null +++ b/tools/lint-openapi-regression/src/LintOpenAPI/Ignore.hs @@ -0,0 +1,70 @@ +module LintOpenAPI.Ignore + ( IgnoreMap, + readIgnoreFile, + writeIgnoreFile, + isIgnored, + updateIgnoreMap, + getRouteIdentifier, + ) +where + +import Data.Aeson (decodeStrict) +import Data.Aeson.Encode.Pretty (encodePretty) +import Data.ByteString qualified as BS +import Data.ByteString.Lazy qualified as LBS +import Data.Map.Strict qualified as Map +import Data.Set qualified as Set +import Data.Text qualified as Text +import Imports +import LintOpenAPI.Report (renderRouteKey) +import LintOpenAPI.Types + +-- | Maps a baseline version string (e.g. "v7") to a set of route identifiers. +type IgnoreMap = Map.Map Text (Set.Set Text) + +-- | Read an ignore file. Returns an empty map if it doesn't exist or is invalid. +readIgnoreFile :: FilePath -> IO IgnoreMap +readIgnoreFile path = do + exists <- doesFileExist path + if not exists + then pure Map.empty + else do + bytes <- BS.readFile path + case decodeStrict bytes of + Just m -> pure m + Nothing -> pure Map.empty + +-- | Write an ignore file using pretty JSON formatting. +writeIgnoreFile :: FilePath -> IgnoreMap -> IO () +writeIgnoreFile path imap = do + let bytes = encodePretty imap + LBS.writeFile path bytes + +-- | Extract the identifier for a route (either its operationId or rendered path). +getRouteIdentifier :: ViolationContext -> Text +getRouteIdentifier ctx = + fromMaybe (renderRouteKey ctx.routeKey) ctx.routeId + +-- | Format the version key for the ignore map. +versionKey :: Maybe Int -> Text +versionKey (Just v) = "v" <> Text.pack (show v) +versionKey Nothing = "baseline" + +-- | Check if a violation is ignored by the given ignore map. +isIgnored :: IgnoreMap -> ViolationContext -> Bool +isIgnored imap ctx = + let vkey = versionKey ctx.baselineVersion + rkey = getRouteIdentifier ctx + in case Map.lookup vkey imap of + Just ignoredRoutes -> Set.member rkey ignoredRoutes + Nothing -> False + +-- | Update the ignore map with new unignored violations. +updateIgnoreMap :: IgnoreMap -> [ViolationContext] -> IgnoreMap +updateIgnoreMap imap newViolations = + foldl' addViolation imap newViolations + where + addViolation acc ctx = + let vkey = versionKey ctx.baselineVersion + rkey = getRouteIdentifier ctx + in Map.insertWith Set.union vkey (Set.singleton rkey) acc diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Normalize.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Normalize.hs new file mode 100644 index 00000000000..7b7a68a1ad6 --- /dev/null +++ b/tools/lint-openapi-regression/src/LintOpenAPI/Normalize.hs @@ -0,0 +1,45 @@ +module LintOpenAPI.Normalize + ( normalizePath, + routeKeyFromPath, + ) +where + +import Data.Text qualified as Text +import Imports +import LintOpenAPI.Types + +-- | Normalize a URL path into a list of route segments. +-- Placeholders like @{userId}@ become 'Placeholder', everything else 'Literal'. +normalizePath :: Text -> NormalizedRoute +normalizePath path = + NormalizedRoute + { segments = map normalizeSegment (filter (not . Text.null) (Text.splitOn "/" path)) + } + +-- | Normalize a single path segment. +normalizeSegment :: Text -> RouteSegment +normalizeSegment seg + | Text.isPrefixOf "{" seg && Text.isSuffixOf "}" seg = Placeholder + | otherwise = Literal seg + +-- | Parse an HTTP method string into our enum. +parseMethod :: Text -> Maybe HttpMethod +parseMethod = \case + "get" -> Just GET + "post" -> Just POST + "put" -> Just PUT + "patch" -> Just PATCH + "delete" -> Just DELETE + "head" -> Just HEAD + "options" -> Just OPTIONS + _ -> Nothing + +-- | Build a 'RouteKey' from a path string and method string. +routeKeyFromPath :: Text -> Text -> Maybe RouteKey +routeKeyFromPath path methodText = do + m <- parseMethod (Text.toLower methodText) + pure + RouteKey + { method = m, + route = normalizePath path + } diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Parse.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Parse.hs new file mode 100644 index 00000000000..9ec34ded694 --- /dev/null +++ b/tools/lint-openapi-regression/src/LintOpenAPI/Parse.hs @@ -0,0 +1,242 @@ +module LintOpenAPI.Parse + ( parseOpenAPIFile, + parseOpenAPIValue, + ) +where + +import Data.Aeson (Value (..), eitherDecodeFileStrict') +import Data.Aeson.Key qualified as Key +import Data.Aeson.KeyMap qualified as KM +import Data.Map.Strict qualified as Map +import Data.Set qualified as Set +import Data.Text qualified as Text +import Data.Vector qualified as Vector +import Imports +import LintOpenAPI.Normalize +import LintOpenAPI.Types + +-- | Parse an OpenAPI JSON file from disk into an 'OpenAPISpec'. +parseOpenAPIFile :: FilePath -> IO (Either String OpenAPISpec) +parseOpenAPIFile fp = do + result <- eitherDecodeFileStrict' fp + pure $ result >>= parseOpenAPIValue + +-- | Parse an OpenAPI JSON 'Value' into an 'OpenAPISpec'. +parseOpenAPIValue :: Value -> Either String OpenAPISpec +parseOpenAPIValue val = case val of + Object top -> do + let schemas = extractSchemas top + ver = extractVersion top + rs <- extractRoutes schemas top + Right + OpenAPISpec + { version = ver, + routes = rs + } + _ -> Left "Top-level value must be a JSON object" + +-- | Extract the API version from servers[0].url (e.g., "/v5" -> 5). +extractVersion :: KM.KeyMap Value -> Maybe Int +extractVersion top = do + Array servers <- KM.lookup "servers" top + Object server <- servers Vector.!? 0 + String url <- KM.lookup "url" server + vText <- Text.stripPrefix "/v" url + readMaybe (Text.unpack vText) + +-- | Extract the component schemas map for $ref resolution. +extractSchemas :: KM.KeyMap Value -> Map.Map Text Value +extractSchemas top = case KM.lookup "components" top of + Just (Object comps) -> case KM.lookup "schemas" comps of + Just (Object schemaMap) -> + Map.fromList + [(Key.toText k, v) | (k, v) <- KM.toList schemaMap] + _ -> Map.empty + _ -> Map.empty + +-- | Extract all routes from the paths object. +extractRoutes :: + Map.Map Text Value -> + KM.KeyMap Value -> + Either String (Map.Map RouteKey RouteInfo) +extractRoutes schemas top = case KM.lookup "paths" top of + Just (Object pathsObj) -> do + let pairs = + [ (rk, ri) + | (pathKey, pathVal) <- KM.toList pathsObj, + let path = Key.toText pathKey, + (rk, ri) <- extractPathMethods schemas path pathVal + ] + Right (Map.fromList pairs) + Just _ -> Left "'paths' must be an object" + Nothing -> Left "Missing 'paths' key" + +-- | Extract all methods from a single path item. +extractPathMethods :: + Map.Map Text Value -> + Text -> + Value -> + [(RouteKey, RouteInfo)] +extractPathMethods schemas path = \case + Object methodsObj -> + [ (rk, ri) + | (methodKey, methodVal) <- KM.toList methodsObj, + let methodText = Key.toText methodKey, + Just rk <- [routeKeyFromPath path methodText], + let ri = extractRouteInfo schemas methodVal + ] + _ -> [] + +-- | Extract route info (params, body, responses) from an operation object. +extractRouteInfo :: Map.Map Text Value -> Value -> RouteInfo +extractRouteInfo schemas = \case + Object op -> + let params = extractParams op + body = extractRequestBody schemas op + resps = extractResponses schemas op + opId = case KM.lookup "operationId" op of + Just (String t) -> Just t + _ -> Nothing + in RouteInfo + { operationId = opId, + queryParams = fst params, + requiredQueryParams = snd params, + requestBody = body, + responses = resps + } + _ -> + RouteInfo + { operationId = Nothing, + queryParams = Set.empty, + requiredQueryParams = Set.empty, + requestBody = Nothing, + responses = Map.empty + } + +-- | Extract query parameters: (all query param names, required query param names). +extractParams :: KM.KeyMap Value -> (Set.Set Text, Set.Set Text) +extractParams op = case KM.lookup "parameters" op of + Just (Array arr) -> + let queryPs = + [ (name, isReq) + | Object p <- Vector.toList arr, + String loc <- [fromMaybe Null (KM.lookup "in" p)], + loc == "query", + String name <- [fromMaybe Null (KM.lookup "name" p)], + let isReq = case KM.lookup "required" p of + Just (Bool True) -> True + _ -> False + ] + in ( Set.fromList (map fst queryPs), + Set.fromList [n | (n, True) <- queryPs] + ) + _ -> (Set.empty, Set.empty) + +-- | Extract and resolve the request body schema. +extractRequestBody :: Map.Map Text Value -> KM.KeyMap Value -> Maybe ResolvedSchema +extractRequestBody schemas op = do + Object rb <- KM.lookup "requestBody" op + Object content <- KM.lookup "content" rb + -- Take the first content type's schema + (_, contentVal) <- listToMaybe (KM.toList content) + case contentVal of + Object ct -> do + schemaVal <- KM.lookup "schema" ct + Just (resolveSchema schemas Set.empty schemaVal) + _ -> Nothing + +-- | Extract and resolve response schemas keyed by status code. +extractResponses :: Map.Map Text Value -> KM.KeyMap Value -> Map.Map Text ResolvedSchema +extractResponses schemas op = case KM.lookup "responses" op of + Just (Object respsObj) -> + Map.fromList + [ (Key.toText code, resolveSchema schemas Set.empty schemaVal) + | (code, respVal) <- KM.toList respsObj, + Object resp <- [respVal], + Object content <- [fromMaybe Null (KM.lookup "content" resp)], + (_, contentVal) <- take 1 (KM.toList content), + Object ct <- [contentVal], + schemaVal <- maybeToList (KM.lookup "schema" ct) + ] + _ -> Map.empty + +-- | Resolve a schema value, following $ref pointers and merging allOf. +-- The 'visited' set prevents infinite recursion on circular references. +resolveSchema :: Map.Map Text Value -> Set.Set Text -> Value -> ResolvedSchema +resolveSchema schemas visited = \case + Object obj + | Just (String ref) <- KM.lookup "$ref" obj -> + resolveRef schemas visited ref + | Just (Array allOfArr) <- KM.lookup "allOf" obj -> + mergeAllOf schemas visited (Vector.toList allOfArr) + | Just (Array oneOfArr) <- KM.lookup "oneOf" obj -> + -- For oneOf, we take the union of all variants' properties. + -- This is conservative: if any variant has a field, we track it. + mergeAllOf schemas visited (Vector.toList oneOfArr) + | otherwise -> + resolveInlineSchema schemas visited obj + _ -> emptySchema + +-- | Resolve a $ref pointer like "#/components/schemas/Foo". +resolveRef :: Map.Map Text Value -> Set.Set Text -> Text -> ResolvedSchema +resolveRef schemas visited ref = + let schemaName = last (Text.splitOn "/" ref) + in if Set.member schemaName visited + then emptySchema + else case Map.lookup schemaName schemas of + Just val -> resolveSchema schemas (Set.insert schemaName visited) val + Nothing -> emptySchema + +-- | Merge multiple schemas from an allOf array. +mergeAllOf :: Map.Map Text Value -> Set.Set Text -> [Value] -> ResolvedSchema +mergeAllOf schemas visited vals = + let resolved = map (resolveSchema schemas visited) vals + in foldl' mergeSchemas emptySchema resolved + +-- | Merge two resolved schemas, combining required fields and properties. +mergeSchemas :: ResolvedSchema -> ResolvedSchema -> ResolvedSchema +mergeSchemas a b = + ResolvedSchema + { requiredFields = a.requiredFields <> b.requiredFields, + properties = Map.union a.properties b.properties, + enumValues = case (a.enumValues, b.enumValues) of + (Nothing, x) -> x + (x, Nothing) -> x + (Just x, Just y) -> Just (Set.union x y), + schemaType = a.schemaType <|> b.schemaType + } + +-- | Resolve an inline schema object (with properties, required, enum, type). +resolveInlineSchema :: + Map.Map Text Value -> + Set.Set Text -> + KM.KeyMap Value -> + ResolvedSchema +resolveInlineSchema schemas visited obj = + let reqFields = case KM.lookup "required" obj of + Just (Array arr) -> + Set.fromList [t | String t <- Vector.toList arr] + _ -> Set.empty + + props = case KM.lookup "properties" obj of + Just (Object propsObj) -> + Map.fromList + [ (Key.toText k, resolveSchema schemas visited v) + | (k, v) <- KM.toList propsObj + ] + _ -> Map.empty + + enumVals = case KM.lookup "enum" obj of + Just (Array arr) -> + Just (Set.fromList [t | String t <- Vector.toList arr]) + _ -> Nothing + + typ = case KM.lookup "type" obj of + Just (String t) -> Just t + _ -> Nothing + in ResolvedSchema + { requiredFields = reqFields, + properties = props, + enumValues = enumVals, + schemaType = typ + } diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Report.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Report.hs new file mode 100644 index 00000000000..1626bccafa3 --- /dev/null +++ b/tools/lint-openapi-regression/src/LintOpenAPI/Report.hs @@ -0,0 +1,108 @@ +module LintOpenAPI.Report + ( formatViolations, + formatViolation, + summarize, + renderRoute, + renderRouteKey, + ) +where + +import Data.Map.Strict qualified as Map +import Data.Set qualified as Set +import Data.Text qualified as Text +import Imports +import LintOpenAPI.Types + +formatViolations :: Int -> [ViolationContext] -> Text +formatViolations ignoredCount [] = + if ignoredCount > 0 + then "No breaking changes detected (" <> Text.pack (show ignoredCount) <> " ignored)." + else "No breaking changes detected." +formatViolations ignoredCount ctxs = + let grouped = Map.fromListWith (++) [(ctx.baselineVersion, [ctx]) | ctx <- reverse ctxs] + sortedGroups = Map.toDescList grouped + formatGroup (ver, groupCtxs) = + let verLabel = case ver of + Just v -> "v" <> Text.pack (show v) + Nothing -> "baseline" + in "### Breaking changes against " + <> verLabel + <> "\n\n" + <> Text.unlines (map formatViolation groupCtxs) + in Text.intercalate "\n" (map formatGroup sortedGroups) + <> "\n" + <> summarize ignoredCount ctxs + +-- | Format a single violation with its context. +formatViolation :: ViolationContext -> Text +formatViolation ctx = + let routeLabel = renderRouteKey ctx.routeKey + opIdLabel = case ctx.routeId of + Just oid -> " (" <> oid <> ")" + Nothing -> "" + detail = renderViolation ctx.violation + in "- " <> routeLabel <> opIdLabel <> ": " <> detail + +-- | Render a route key as "METHOD /path/{param}". +renderRouteKey :: RouteKey -> Text +renderRouteKey rk = + Text.pack (show rk.method) <> " " <> renderRoute rk.route + +-- | Render a normalized route back to path format. +renderRoute :: NormalizedRoute -> Text +renderRoute nr = + "/" <> Text.intercalate "/" (map renderSegment nr.segments) + +-- | Render a single route segment. +renderSegment :: RouteSegment -> Text +renderSegment = \case + Literal t -> t + Placeholder -> "{_}" + +-- | Render a violation detail message. +renderViolation :: Violation -> Text +renderViolation = \case + RouteRemoved -> + "Route removed" + QueryParamRemoved name -> + "Query parameter removed: \"" <> name <> "\"" + RequiredQueryParamAdded name -> + "Required query parameter added: \"" <> name <> "\"" + RequiredBodyFieldAdded name -> + "Required body field added: \"" <> name <> "\"" + ResponseFieldRemoved name -> + "Response field removed: \"" <> name <> "\" (was required)" + EnumValueRemoved field val -> + let fieldPart = + if Text.null field + then "" + else " from field \"" <> field <> "\"" + in "Enum value removed" <> fieldPart <> ": \"" <> val <> "\"" + EnumValueAdded field val -> + let fieldPart = + if Text.null field + then "" + else " to field \"" <> field <> "\"" + in "Enum value added" <> fieldPart <> ": \"" <> val <> "\"" + +-- | Generate a summary line. +summarize :: Int -> [ViolationContext] -> Text +summarize ignoredCount ctxs = + let count = length ctxs + versions = + Set.fromList + [v | ViolationContext {baselineVersion = Just v} <- ctxs] + verCount = Set.size versions + ignoredText = + if ignoredCount > 0 + then " (" <> Text.pack (show ignoredCount) <> " ignored)" + else "" + in "Summary: " + <> Text.pack (show count) + <> " breaking change" + <> (if count == 1 then "" else "s") + <> ignoredText + <> " detected across " + <> Text.pack (show verCount) + <> " baseline version" + <> (if verCount == 1 then "" else "s") diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Types.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Types.hs new file mode 100644 index 00000000000..82aed1a5c6f --- /dev/null +++ b/tools/lint-openapi-regression/src/LintOpenAPI/Types.hs @@ -0,0 +1,101 @@ +module LintOpenAPI.Types + ( RouteSegment (..), + NormalizedRoute (..), + HttpMethod (..), + RouteKey (..), + ResolvedSchema (..), + RouteInfo (..), + Violation (..), + ViolationContext (..), + OpenAPISpec (..), + emptySchema, + ) +where + +import Imports + +-- | A segment of a URL path, either a literal string or a placeholder. +data RouteSegment + = Literal Text + | Placeholder + deriving stock (Eq, Ord, Show, Generic) + +-- | A normalized route path where all placeholders are unified. +newtype NormalizedRoute = NormalizedRoute + { segments :: [RouteSegment] + } + deriving stock (Eq, Ord, Show, Generic) + +-- | HTTP methods relevant for OpenAPI comparison. +data HttpMethod + = GET + | POST + | PUT + | PATCH + | DELETE + | HEAD + | OPTIONS + deriving stock (Eq, Ord, Show, Generic, Enum, Bounded) + +-- | Unique identity of a route across API versions. +data RouteKey = RouteKey + { method :: HttpMethod, + route :: NormalizedRoute + } + deriving stock (Eq, Ord, Show, Generic) + +-- | A resolved (flattened) schema with all $ref pointers followed. +data ResolvedSchema = ResolvedSchema + { requiredFields :: Set Text, + properties :: Map Text ResolvedSchema, + enumValues :: Maybe (Set Text), + schemaType :: Maybe Text + } + deriving stock (Eq, Show, Generic) + +-- | An empty schema with no fields, no enum, no type. +emptySchema :: ResolvedSchema +emptySchema = + ResolvedSchema + { requiredFields = mempty, + properties = mempty, + enumValues = Nothing, + schemaType = Nothing + } + +-- | All information about a route needed for backward-compat checking. +data RouteInfo = RouteInfo + { operationId :: Maybe Text, + queryParams :: Set Text, + requiredQueryParams :: Set Text, + requestBody :: Maybe ResolvedSchema, + responses :: Map Text ResolvedSchema + } + deriving stock (Eq, Show, Generic) + +-- | A parsed OpenAPI specification. +data OpenAPISpec = OpenAPISpec + { version :: Maybe Int, + routes :: Map RouteKey RouteInfo + } + deriving stock (Eq, Show, Generic) + +-- | A specific backward-incompatible change detected. +data Violation + = RouteRemoved + | QueryParamRemoved Text + | RequiredQueryParamAdded Text + | RequiredBodyFieldAdded Text + | ResponseFieldRemoved Text + | EnumValueRemoved Text Text + | EnumValueAdded Text Text + deriving stock (Eq, Show, Generic) + +-- | A violation together with its context for reporting. +data ViolationContext = ViolationContext + { baselineVersion :: Maybe Int, + routeKey :: RouteKey, + routeId :: Maybe Text, + violation :: Violation + } + deriving stock (Eq, Show, Generic) diff --git a/tools/lint-openapi-regression/test/LintOpenAPI/CompareSpec.hs b/tools/lint-openapi-regression/test/LintOpenAPI/CompareSpec.hs new file mode 100644 index 00000000000..3f52981de67 --- /dev/null +++ b/tools/lint-openapi-regression/test/LintOpenAPI/CompareSpec.hs @@ -0,0 +1,199 @@ +module LintOpenAPI.CompareSpec (spec) where + +import Data.Map.Strict qualified as Map +import Data.Set qualified as Set +import Imports +import LintOpenAPI.Compare +import LintOpenAPI.Types +import Test.Hspec + +spec :: Spec +spec = describe "LintOpenAPI.Compare" $ do + describe "compareSpecs" $ do + it "detects route removal" $ do + let baseline = mkOpenAPISpec (Just 5) [(getUsers, emptyRouteInfo)] + candidate = mkOpenAPISpec Nothing [] + violations = compareSpecs baseline candidate + length violations `shouldBe` 1 + case listToMaybe violations of + Just ctx -> ctx.violation `shouldBe` RouteRemoved + Nothing -> expectationFailure "Expected 1 violation" + + it "passes when route is preserved" $ do + let baseline = mkOpenAPISpec (Just 5) [(getUsers, emptyRouteInfo)] + candidate = mkOpenAPISpec Nothing [(getUsers, emptyRouteInfo)] + violations = compareSpecs baseline candidate + violations `shouldBe` [] + + it "ignores new routes in candidate" $ do + let baseline = mkOpenAPISpec (Just 5) [(getUsers, emptyRouteInfo)] + candidate = mkOpenAPISpec Nothing [(getUsers, emptyRouteInfo), (postTeams, emptyRouteInfo)] + violations = compareSpecs baseline candidate + violations `shouldBe` [] + + describe "compareRouteInfo" $ do + it "detects query param removal" $ do + let baseline = emptyRouteInfo {queryParams = Set.fromList ["size", "start"]} + candidate = emptyRouteInfo {queryParams = Set.singleton "start"} + violations = compareRouteInfo baseline candidate + violations `shouldBe` [QueryParamRemoved "size"] + + it "detects new required query param" $ do + let baseline = emptyRouteInfo {queryParams = Set.singleton "start"} + candidate = emptyRouteInfo {queryParams = Set.fromList ["start", "filter"], requiredQueryParams = Set.singleton "filter"} + violations = compareRouteInfo baseline candidate + violations `shouldBe` [RequiredQueryParamAdded "filter"] + + it "allows new optional query param" $ do + let baseline = emptyRouteInfo {queryParams = Set.singleton "start"} + candidate = emptyRouteInfo {queryParams = Set.fromList ["start", "sort"]} + violations = compareRouteInfo baseline candidate + violations `shouldBe` [] + + it "detects new required body field" $ do + let baseBody = emptySchema {requiredFields = Set.singleton "name"} + candBody = emptySchema {requiredFields = Set.fromList ["name", "team_id"]} + baseline = emptyRouteInfo {requestBody = Just baseBody} + candidate = emptyRouteInfo {requestBody = Just candBody} + violations = compareRouteInfo baseline candidate + violations `shouldBe` [RequiredBodyFieldAdded "team_id"] + + it "allows new optional body field" $ do + let baseBody = + emptySchema + { requiredFields = Set.singleton "name", + properties = Map.singleton "name" emptySchema + } + candBody = + emptySchema + { requiredFields = Set.singleton "name", + properties = Map.fromList [("name", emptySchema), ("nickname", emptySchema)] + } + baseline = emptyRouteInfo {requestBody = Just baseBody} + candidate = emptyRouteInfo {requestBody = Just candBody} + violations = compareRouteInfo baseline candidate + violations `shouldBe` [] + + it "detects removed required response field" $ do + let baseResp = + emptySchema + { requiredFields = Set.fromList ["id", "phone"], + properties = Map.fromList [("id", emptySchema), ("phone", emptySchema)] + } + candResp = + emptySchema + { requiredFields = Set.singleton "id", + properties = Map.singleton "id" emptySchema + } + baseline = emptyRouteInfo {responses = Map.singleton "200" baseResp} + candidate = emptyRouteInfo {responses = Map.singleton "200" candResp} + violations = compareRouteInfo baseline candidate + violations `shouldBe` [ResponseFieldRemoved "phone"] + + it "allows removal of optional response field" $ do + let baseResp = + emptySchema + { requiredFields = Set.singleton "id", + properties = Map.fromList [("id", emptySchema), ("legacy", emptySchema)] + } + candResp = + emptySchema + { requiredFields = Set.singleton "id", + properties = Map.singleton "id" emptySchema + } + baseline = emptyRouteInfo {responses = Map.singleton "200" baseResp} + candidate = emptyRouteInfo {responses = Map.singleton "200" candResp} + violations = compareRouteInfo baseline candidate + violations `shouldBe` [] + + it "detects enum value removed in request body" $ do + let baseBody = emptySchema {enumValues = Just (Set.fromList ["active", "expired"])} + candBody = emptySchema {enumValues = Just (Set.singleton "active")} + baseline = emptyRouteInfo {requestBody = Just baseBody} + candidate = emptyRouteInfo {requestBody = Just candBody} + violations = compareRouteInfo baseline candidate + violations `shouldBe` [EnumValueRemoved "" "expired"] + + it "allows enum value addition in request body" $ do + let baseBody = emptySchema {enumValues = Just (Set.singleton "active")} + candBody = emptySchema {enumValues = Just (Set.fromList ["active", "archived"])} + baseline = emptyRouteInfo {requestBody = Just baseBody} + candidate = emptyRouteInfo {requestBody = Just candBody} + violations = compareRouteInfo baseline candidate + violations `shouldBe` [] + + it "allows enum value removed from response body" $ do + let baseResp = emptySchema {enumValues = Just (Set.fromList ["active", "inactive"])} + candResp = emptySchema {enumValues = Just (Set.singleton "active")} + baseline = emptyRouteInfo {responses = Map.singleton "200" baseResp} + candidate = emptyRouteInfo {responses = Map.singleton "200" candResp} + violations = compareRouteInfo baseline candidate + violations `shouldBe` [] + + it "detects enum value added in response body" $ do + let baseResp = emptySchema {enumValues = Just (Set.singleton "active")} + candResp = emptySchema {enumValues = Just (Set.fromList ["active", "archived"])} + baseline = emptyRouteInfo {responses = Map.singleton "200" baseResp} + candidate = emptyRouteInfo {responses = Map.singleton "200" candResp} + violations = compareRouteInfo baseline candidate + violations `shouldBe` [EnumValueAdded "" "archived"] + + it "handles placeholder name changes (treated as same route)" $ do + let rk1 = + RouteKey + { method = GET, + route = NormalizedRoute [Literal "users", Placeholder] + } + rk2 = rk1 -- Same structure, normalized + baseline = mkOpenAPISpec (Just 5) [(rk1, emptyRouteInfo)] + candidate = mkOpenAPISpec Nothing [(rk2, emptyRouteInfo)] + violations = compareSpecs baseline candidate + violations `shouldBe` [] + + it "handles body appearing where none existed" $ do + let candBody = emptySchema {requiredFields = Set.singleton "team"} + baseline = emptyRouteInfo {requestBody = Nothing} + candidate = emptyRouteInfo {requestBody = Just candBody} + violations = compareRouteInfo baseline candidate + violations `shouldBe` [RequiredBodyFieldAdded "team"] + + it "handles body disappearing (no violation)" $ do + let baseBody = emptySchema {requiredFields = Set.singleton "name"} + baseline = emptyRouteInfo {requestBody = Just baseBody} + candidate = emptyRouteInfo {requestBody = Nothing} + violations = compareRouteInfo baseline candidate + violations `shouldBe` [] + +-- | Helper route keys for tests. +getUsers :: RouteKey +getUsers = + RouteKey + { method = GET, + route = NormalizedRoute [Literal "users"] + } + +postTeams :: RouteKey +postTeams = + RouteKey + { method = POST, + route = NormalizedRoute [Literal "teams"] + } + +-- | Helper to build an empty route info. +emptyRouteInfo :: RouteInfo +emptyRouteInfo = + RouteInfo + { operationId = Nothing, + queryParams = Set.empty, + requiredQueryParams = Set.empty, + requestBody = Nothing, + responses = Map.empty + } + +-- | Helper to build a spec from route key/info pairs. +mkOpenAPISpec :: Maybe Int -> [(RouteKey, RouteInfo)] -> OpenAPISpec +mkOpenAPISpec ver rs = + OpenAPISpec + { version = ver, + routes = Map.fromList rs + } diff --git a/tools/lint-openapi-regression/test/LintOpenAPI/IgnoreSpec.hs b/tools/lint-openapi-regression/test/LintOpenAPI/IgnoreSpec.hs new file mode 100644 index 00000000000..36f89485079 --- /dev/null +++ b/tools/lint-openapi-regression/test/LintOpenAPI/IgnoreSpec.hs @@ -0,0 +1,57 @@ +module LintOpenAPI.IgnoreSpec (spec) where + +import Data.Map.Strict qualified as Map +import Data.Set qualified as Set +import Data.Text qualified as Text +import Imports +import LintOpenAPI.Ignore +import LintOpenAPI.Types +import Test.Hspec + +spec :: Spec +spec = describe "LintOpenAPI.Ignore" $ do + describe "isIgnored" $ do + it "ignores a violation by operationId" $ do + let imap = Map.singleton "v5" (Set.singleton "my-route") + ctx = mkCtx (Just 5) (Just "my-route") GET "/path" + isIgnored imap ctx `shouldBe` True + + it "ignores a violation by rendered route" $ do + let imap = Map.singleton "v5" (Set.singleton "GET /path/{_}") + ctx = mkCtx (Just 5) Nothing GET "/path/{_}" + isIgnored imap ctx `shouldBe` True + + it "does not ignore if version mismatch" $ do + let imap = Map.singleton "v6" (Set.singleton "my-route") + ctx = mkCtx (Just 5) (Just "my-route") GET "/path" + isIgnored imap ctx `shouldBe` False + + it "does not ignore if route mismatch" $ do + let imap = Map.singleton "v5" (Set.singleton "other-route") + ctx = mkCtx (Just 5) (Just "my-route") GET "/path" + isIgnored imap ctx `shouldBe` False + + describe "updateIgnoreMap" $ do + it "adds new violations to the ignore map" $ do + let imap = Map.singleton "v5" (Set.singleton "old-route") + ctx1 = mkCtx (Just 5) (Just "new-route") GET "/path1" + ctx2 = mkCtx (Just 6) Nothing POST "/path2" + newMap = updateIgnoreMap imap [ctx1, ctx2] + + Map.lookup "v5" newMap `shouldBe` Just (Set.fromList ["old-route", "new-route"]) + Map.lookup "v6" newMap `shouldBe` Just (Set.singleton "POST /path2") + +-- | Helper to create a ViolationContext for testing +mkCtx :: Maybe Int -> Maybe Text -> HttpMethod -> Text -> ViolationContext +mkCtx ver opId method routeStr = + let rk = + RouteKey + { method = method, + route = NormalizedRoute [Literal (Text.drop 1 routeStr)] -- simplistic mock + } + in ViolationContext + { baselineVersion = ver, + routeKey = rk, + routeId = opId, + violation = RouteRemoved + } diff --git a/tools/lint-openapi-regression/test/LintOpenAPI/NormalizeSpec.hs b/tools/lint-openapi-regression/test/LintOpenAPI/NormalizeSpec.hs new file mode 100644 index 00000000000..3543da873b6 --- /dev/null +++ b/tools/lint-openapi-regression/test/LintOpenAPI/NormalizeSpec.hs @@ -0,0 +1,59 @@ +module LintOpenAPI.NormalizeSpec (spec) where + +import Imports +import LintOpenAPI.Normalize +import LintOpenAPI.Types +import Test.Hspec + +spec :: Spec +spec = describe "LintOpenAPI.Normalize" $ do + describe "normalizePath" $ do + it "normalizes a simple path" $ do + normalizePath "/users" + `shouldBe` NormalizedRoute [Literal "users"] + + it "normalizes a path with placeholders" $ do + normalizePath "/users/{userId}/clients/{clientId}" + `shouldBe` NormalizedRoute [Literal "users", Placeholder, Literal "clients", Placeholder] + + it "normalizes root path" $ do + normalizePath "/" + `shouldBe` NormalizedRoute [] + + it "normalizes path without leading slash" $ do + normalizePath "users/{id}" + `shouldBe` NormalizedRoute [Literal "users", Placeholder] + + it "normalizes path with multiple consecutive slashes" $ do + -- splitting on "/" with empty segments filtered out + normalizePath "/users//clients" + `shouldBe` NormalizedRoute [Literal "users", Literal "clients"] + + it "treats different placeholder names as equal" $ do + normalizePath "/users/{userId}" + `shouldBe` normalizePath "/users/{uid}" + + it "treats different literal segments as different" $ do + normalizePath "/users" + `shouldNotBe` normalizePath "/teams" + + describe "routeKeyFromPath" $ do + it "creates a route key for GET" $ do + let result = routeKeyFromPath "/users/{id}" "get" + result `shouldBe` Just RouteKey {method = GET, route = NormalizedRoute [Literal "users", Placeholder]} + + it "creates a route key for POST (case insensitive)" $ do + let result = routeKeyFromPath "/teams" "POST" + result `shouldBe` Just RouteKey {method = POST, route = NormalizedRoute [Literal "teams"]} + + it "returns Nothing for unknown methods" $ do + routeKeyFromPath "/foo" "UNKNOWN" `shouldBe` Nothing + + it "handles all HTTP methods" $ do + routeKeyFromPath "/x" "get" `shouldSatisfy` isJust + routeKeyFromPath "/x" "post" `shouldSatisfy` isJust + routeKeyFromPath "/x" "put" `shouldSatisfy` isJust + routeKeyFromPath "/x" "patch" `shouldSatisfy` isJust + routeKeyFromPath "/x" "delete" `shouldSatisfy` isJust + routeKeyFromPath "/x" "head" `shouldSatisfy` isJust + routeKeyFromPath "/x" "options" `shouldSatisfy` isJust diff --git a/tools/lint-openapi-regression/test/LintOpenAPI/ParseSpec.hs b/tools/lint-openapi-regression/test/LintOpenAPI/ParseSpec.hs new file mode 100644 index 00000000000..e4cb32936df --- /dev/null +++ b/tools/lint-openapi-regression/test/LintOpenAPI/ParseSpec.hs @@ -0,0 +1,275 @@ +module LintOpenAPI.ParseSpec (spec) where + +import Data.Aeson (Value (..), object, (.=)) +import Data.Aeson.Key qualified as Key +import Data.Map.Strict qualified as Map +import Data.Set qualified as Set +import Imports +import LintOpenAPI.Parse +import LintOpenAPI.Types +import Test.Hspec + +spec :: Spec +spec = describe "LintOpenAPI.Parse" $ do + describe "parseOpenAPIValue" $ do + it "parses a minimal OpenAPI spec" $ do + let val = + object + [ "openapi" .= ("3.0.0" :: Text), + "paths" .= object [], + "info" .= object ["title" .= ("Test" :: Text), "version" .= ("" :: Text)] + ] + case parseOpenAPIValue val of + Left err -> expectationFailure $ "Parse failed: " <> err + Right spec' -> do + spec'.version `shouldBe` Nothing + Map.size spec'.routes `shouldBe` 0 + + it "extracts version from servers" $ do + let val = + object + [ "openapi" .= ("3.0.0" :: Text), + "paths" .= object [], + "servers" .= [object ["url" .= ("/v5" :: Text)]] + ] + case parseOpenAPIValue val of + Left err -> expectationFailure $ "Parse failed: " <> err + Right spec' -> spec'.version `shouldBe` Just 5 + + it "parses a route with query parameters" $ do + let val = mkSpec [("/users", "get", mkOperation [mkQueryParam "size" False, mkQueryParam "start" True] Nothing [])] + case parseOpenAPIValue val of + Left err -> expectationFailure $ "Parse failed: " <> err + Right spec' -> do + length spec'.routes `shouldBe` 1 + ri <- headRoute spec'.routes + ri.queryParams `shouldBe` Set.fromList ["size", "start"] + ri.requiredQueryParams `shouldBe` Set.singleton "start" + + it "parses a route with request body" $ do + let bodySchema = + object + [ "type" .= ("object" :: Text), + "required" .= (["name", "email"] :: [Text]), + "properties" + .= object + [ "name" .= object ["type" .= ("string" :: Text)], + "email" .= object ["type" .= ("string" :: Text)], + "nickname" .= object ["type" .= ("string" :: Text)] + ] + ] + val = mkSpec [("/users", "post", mkOperation [] (Just bodySchema) [])] + case parseOpenAPIValue val of + Left err -> expectationFailure $ "Parse failed: " <> err + Right spec' -> do + ri <- headRoute spec'.routes + case ri.requestBody of + Nothing -> expectationFailure "Expected request body" + Just schema -> do + schema.requiredFields `shouldBe` Set.fromList ["name", "email"] + Map.keys schema.properties `shouldSatisfy` ("nickname" `elem`) + + it "parses a route with response body" $ do + let respSchema = + object + [ "type" .= ("object" :: Text), + "required" .= (["id", "name"] :: [Text]), + "properties" + .= object + [ "id" .= object ["type" .= ("string" :: Text)], + "name" .= object ["type" .= ("string" :: Text)], + "phone" .= object ["type" .= ("string" :: Text)] + ] + ] + val = mkSpec [("/self", "get", mkOperation [] Nothing [("200", respSchema)])] + case parseOpenAPIValue val of + Left err -> expectationFailure $ "Parse failed: " <> err + Right spec' -> do + ri <- headRoute spec'.routes + case Map.lookup "200" ri.responses of + Nothing -> expectationFailure "Expected 200 response" + Just schema -> do + schema.requiredFields `shouldBe` Set.fromList ["id", "name"] + Map.size schema.properties `shouldBe` 3 + + it "resolves $ref in request body" $ do + let val = + object + [ "openapi" .= ("3.0.0" :: Text), + "paths" + .= object + [ "/users" + .= object + [ "post" + .= object + [ "requestBody" + .= object + [ "content" + .= object + [ "application/json" + .= object + ["schema" .= object ["$ref" .= ("#/components/schemas/User" :: Text)]] + ] + ] + ] + ] + ], + "components" + .= object + [ "schemas" + .= object + [ "User" + .= object + [ "type" .= ("object" :: Text), + "required" .= (["name"] :: [Text]), + "properties" + .= object + [ "name" .= object ["type" .= ("string" :: Text)] + ] + ] + ] + ] + ] + case parseOpenAPIValue val of + Left err -> expectationFailure $ "Parse failed: " <> err + Right spec' -> do + ri <- headRoute spec'.routes + case ri.requestBody of + Nothing -> expectationFailure "Expected request body" + Just schema -> do + schema.requiredFields `shouldBe` Set.singleton "name" + Map.keys schema.properties `shouldBe` ["name"] + + it "resolves allOf schemas" $ do + let val = + object + [ "openapi" .= ("3.0.0" :: Text), + "paths" + .= object + [ "/items" + .= object + [ "post" + .= object + [ "requestBody" + .= object + [ "content" + .= object + [ "application/json" + .= object + [ "schema" + .= object + [ "allOf" + .= [ object ["$ref" .= ("#/components/schemas/Base" :: Text)], + object + [ "type" .= ("object" :: Text), + "required" .= (["extra"] :: [Text]), + "properties" .= object ["extra" .= object ["type" .= ("string" :: Text)]] + ] + ] + ] + ] + ] + ] + ] + ] + ], + "components" + .= object + [ "schemas" + .= object + [ "Base" + .= object + [ "type" .= ("object" :: Text), + "required" .= (["id"] :: [Text]), + "properties" .= object ["id" .= object ["type" .= ("string" :: Text)]] + ] + ] + ] + ] + case parseOpenAPIValue val of + Left err -> expectationFailure $ "Parse failed: " <> err + Right spec' -> do + ri <- headRoute spec'.routes + case ri.requestBody of + Nothing -> expectationFailure "Expected request body" + Just schema -> do + schema.requiredFields `shouldBe` Set.fromList ["id", "extra"] + Map.size schema.properties `shouldBe` 2 + + it "handles enum values" $ do + let bodySchema = + object + [ "type" .= ("string" :: Text), + "enum" .= (["active", "inactive", "suspended"] :: [Text]) + ] + val = mkSpec [("/status", "put", mkOperation [] (Just bodySchema) [])] + case parseOpenAPIValue val of + Left err -> expectationFailure $ "Parse failed: " <> err + Right spec' -> do + ri <- headRoute spec'.routes + case ri.requestBody of + Nothing -> expectationFailure "Expected request body" + Just schema -> + schema.enumValues `shouldBe` Just (Set.fromList ["active", "inactive", "suspended"]) + + it "rejects non-object top-level value" $ do + parseOpenAPIValue (String "not an object") `shouldSatisfy` isLeft + +-- | Helper to build a minimal OpenAPI spec value. +mkSpec :: [(Text, Text, Value)] -> Value +mkSpec routes = + object + [ "openapi" .= ("3.0.0" :: Text), + "paths" .= object (map mkPathEntry routes) + ] + where + mkPathEntry (path, method, op) = + Key.fromText path .= object [Key.fromText method .= op] + +-- | Helper to build an operation object. +mkOperation :: [Value] -> Maybe Value -> [(Text, Value)] -> Value +mkOperation params mBody resps = + object + $ ["parameters" .= params] + <> maybe [] (\b -> ["requestBody" .= mkRequestBody b]) mBody + <> ["responses" .= object (map mkResponse resps)] + +-- | Helper to build a query parameter. +mkQueryParam :: Text -> Bool -> Value +mkQueryParam name required = + object + [ "name" .= name, + "in" .= ("query" :: Text), + "required" .= required + ] + +-- | Helper to wrap a schema in a request body. +mkRequestBody :: Value -> Value +mkRequestBody schema = + object + [ "content" + .= object + [ "application/json" + .= object ["schema" .= schema] + ] + ] + +-- | Helper to wrap a schema in a response. +mkResponse :: (Text, Value) -> (Key.Key, Value) +mkResponse (code, schema) = + Key.fromText code + .= object + [ "content" + .= object + [ "application/json" + .= object ["schema" .= schema] + ] + ] + +-- | Helper to safely get the first route info. +headRoute :: Map.Map RouteKey RouteInfo -> IO RouteInfo +headRoute m = case listToMaybe (Map.elems m) of + Just ri -> pure ri + Nothing -> do + expectationFailure "Expected at least 1 route" + error "unreachable" diff --git a/tools/lint-openapi-regression/test/Spec.hs b/tools/lint-openapi-regression/test/Spec.hs new file mode 100644 index 00000000000..a824f8c30c8 --- /dev/null +++ b/tools/lint-openapi-regression/test/Spec.hs @@ -0,0 +1 @@ +{-# OPTIONS_GHC -F -pgmF hspec-discover #-} From 01e192d55af26e9c16e86076c4cd29aacbf0f5ab Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Thu, 10 Sep 2026 18:32:03 +0200 Subject: [PATCH 02/10] WPB-26627: add --api-version to lint-openapi-regression for frozen-version drift checks Fetch a specific brig OpenAPI version over HTTP and lint it against the saved baseline of that same version (actual VN vs saved VN): - New LintOpenAPI.Baseline module: discoverBaselineFiles et al. moved from Main.hs; selectBaselineFile picks swagger-v.json (fail-closed, OpenAPI-2 names rejected); expectVersion checks a spec's version from servers[0].url. - Main.hs: --api-version N selects the single matching baseline (missing baseline, candidate version mismatch, or targeted-baseline parse failure all exit 2); warns that --ignore may mask drift. - Makefile: openapi-lint-regression target fetches /vN/api/swagger.json (numeric VERSION guard) and lints it; deliberately no --ignore. - README: option row, usage example, drift-masking caveat, exit codes. --- Makefile | 16 +++ tools/lint-openapi-regression/README.md | 17 ++- tools/lint-openapi-regression/default.nix | 2 +- tools/lint-openapi-regression/exe/Main.hs | 115 +++++++++++------- .../lint-openapi-regression.cabal | 4 +- .../src/LintOpenAPI/Baseline.hs | 66 ++++++++++ .../src/LintOpenAPI/Parse.hs | 8 +- .../test/LintOpenAPI/BaselineSpec.hs | 40 ++++++ 8 files changed, 216 insertions(+), 52 deletions(-) create mode 100644 tools/lint-openapi-regression/src/LintOpenAPI/Baseline.hs create mode 100644 tools/lint-openapi-regression/test/LintOpenAPI/BaselineSpec.hs diff --git a/Makefile b/Makefile index 5608b90d341..8ee6798f466 100644 --- a/Makefile +++ b/Makefile @@ -780,3 +780,19 @@ 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=16 [BRIG_URL=] +.PHONY: openapi-lint-regression +openapi-lint-regression: + @if [ -z "$(VERSION)" ]; then \ + echo "usage: make openapi-lint-regression VERSION= [BRIG_URL=]"; \ + exit 1; \ + fi + @case "$(VERSION)" in *[!0-9]*) echo "VERSION must be a positive integer" >&2; exit 1;; esac + curl -fsS "$(BRIG_URL)/v$(VERSION)/api/swagger.json" > /tmp/openapi-actual-v$(VERSION).json + cabal run lint-openapi-regression -- \ + --baseline-dir services/brig/docs \ + --api-version "$(VERSION)" \ + /tmp/openapi-actual-v$(VERSION).json diff --git a/tools/lint-openapi-regression/README.md b/tools/lint-openapi-regression/README.md index 2767c7972ee..beecdf96959 100644 --- a/tools/lint-openapi-regression/README.md +++ b/tools/lint-openapi-regression/README.md @@ -47,8 +47,8 @@ lint-openapi-regression [OPTIONS] INPUT_FILE | Flag | Default | Description | |--------------------|------------------------|------------------------------------------------------| | `--baseline-dir` | `services/brig/docs` | Directory containing baseline `swagger-v*.json` files | -| `--ignore FILE` | (none) | Path to a JSON ignore file | | `--update` | off | Update the ignore file with new breaking changes | +| `--api-version N` | (none) | Compare only against the saved baseline of this version instead of all baselines | ### Examples @@ -76,13 +76,26 @@ Auto-update the ignore file with any new violations found: lint-openapi-regression --ignore .lint-ignore.json --update services/brig/docs/swagger.json ``` +Fetch the OpenAPI served for a specific version from a running brig and compare +it against the saved baseline of that same version (frozen-version drift check; +via `make openapi-lint-regression`): + +```bash +make openapi-lint-regression VERSION=16 +``` + +Note: `.lint-ignore.json`-style ignore files are keyed by baseline version and +record intentional changes *since* each version, so combining `--api-version` +with such a file masks frozen-version drift. Use a separate ignore file if you +need to suppress known drift. + ## Exit codes | Code | Meaning | |------|--------------------------------------------| | 0 | No breaking changes (or all are ignored) | | 1 | Breaking changes detected | -| 2 | Input error (file not found, parse failure)| +| 2 | Input error (file not found, parse failure, version mismatch, missing baseline) | ## Ignore file format diff --git a/tools/lint-openapi-regression/default.nix b/tools/lint-openapi-regression/default.nix index 6da480ab6ad..612e786ee9a 100644 --- a/tools/lint-openapi-regression/default.nix +++ b/tools/lint-openapi-regression/default.nix @@ -27,6 +27,7 @@ mkDerivation { aeson-pretty bytestring containers + filepath imports text vector @@ -34,7 +35,6 @@ mkDerivation { executableHaskellDepends = [ base containers - filepath imports optparse-applicative text diff --git a/tools/lint-openapi-regression/exe/Main.hs b/tools/lint-openapi-regression/exe/Main.hs index 3885e38875c..2d04695f476 100644 --- a/tools/lint-openapi-regression/exe/Main.hs +++ b/tools/lint-openapi-regression/exe/Main.hs @@ -5,13 +5,14 @@ import Data.Map.Strict qualified as Map import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO import Imports +import LintOpenAPI.Baseline (discoverBaselineFiles, expectVersion, selectBaselineFile) import LintOpenAPI.Compare (compareSpecs) import LintOpenAPI.Ignore (isIgnored, readIgnoreFile, updateIgnoreMap, writeIgnoreFile) import LintOpenAPI.Parse (parseOpenAPIFile) import LintOpenAPI.Report (formatViolations) +import LintOpenAPI.Types (OpenAPISpec (..)) import Options.Applicative import System.Exit (ExitCode (..), exitWith) -import System.FilePath (()) import System.IO qualified as IO -- | CLI configuration. @@ -19,7 +20,8 @@ data Options = Options { baselineDir :: FilePath, inputFile :: FilePath, ignoreFile :: Maybe FilePath, - updateIgnore :: Bool + updateIgnore :: Bool, + apiVersion :: Maybe Int } -- | Parse CLI options. @@ -48,6 +50,14 @@ optionsParser = ( long "update" <> help "Update the ignore file with unignored breaking changes" ) + <*> optional + ( option + auto + ( long "api-version" + <> metavar "N" + <> help "Compare only against the saved baseline of this version instead of all baselines" + ) + ) -- | Top-level CLI parser with help text. optionsParserInfo :: ParserInfo Options @@ -82,18 +92,68 @@ runLint opts = do Right spec -> pure spec -- Discover and parse baseline files - baselineFiles <- discoverBaselineFiles opts.baselineDir - when (null baselineFiles) $ do - Text.IO.hPutStrLn IO.stderr $ - "Warning: No baseline swagger-v*.json files found in " <> Text.pack opts.baselineDir - + baselineFiles <- case opts.apiVersion of + Nothing -> do + files <- discoverBaselineFiles opts.baselineDir + when (null files) $ + Text.IO.hPutStrLn IO.stderr $ + "Warning: No baseline swagger-v*.json files found in " <> Text.pack opts.baselineDir + pure files + Just n -> do + files <- discoverBaselineFiles opts.baselineDir + case selectBaselineFile n files of + Left err -> do + Text.IO.hPutStrLn IO.stderr $ "Error: " <> Text.pack err + exitWith (ExitFailure 2) + Right fp -> pure [fp] + + -- When targeting a version, verify the candidate actually is that version + case opts.apiVersion of + Just n -> do + let candidateVersion = case candidate of OpenAPISpec {version = v} -> v + case candidateVersion of + Nothing -> do + Text.IO.hPutStrLn IO.stderr "Error: Candidate spec has no version (servers[0].url)" + exitWith (ExitFailure 2) + Just m + | m /= n -> do + Text.IO.hPutStrLn IO.stderr $ + "Error: Candidate spec is v" + <> Text.pack (show m) + <> " but --api-version v" + <> Text.pack (show n) + <> " was requested" + exitWith (ExitFailure 2) + | otherwise -> pure () + Nothing -> pure () baselines <- forM baselineFiles $ \fp -> do result <- parseOpenAPIFile fp case result of - Left err -> do - Text.IO.hPutStrLn IO.stderr $ "Warning: Failed to parse " <> Text.pack fp <> ": " <> Text.pack err - pure Nothing - Right spec -> pure (Just spec) + Left err -> case opts.apiVersion of + Just _ -> + Text.IO.hPutStrLn IO.stderr ("Error: Failed to parse " <> Text.pack fp <> ": " <> Text.pack err) + >> exitWith (ExitFailure 2) + Nothing -> do + Text.IO.hPutStrLn IO.stderr $ "Warning: Failed to parse " <> Text.pack fp <> ": " <> Text.pack err + pure Nothing + Right spec -> do + case opts.apiVersion of + Just n -> case expectVersion n spec of + Left err -> do + Text.IO.hPutStrLn IO.stderr $ + "Error: " <> Text.pack fp <> ": " <> Text.pack err + exitWith (ExitFailure 2) + Right () -> pure () + Nothing -> pure () + pure (Just spec) + + when (isJust opts.apiVersion) $ + forM_ opts.ignoreFile $ \f -> + Text.IO.hPutStrLn IO.stderr $ + "Warning: ignore entries are keyed by baseline version and record intentional " + <> "changes since that version, so they may mask frozen-version drift in " + <> Text.pack f + <> "; use a separate drift ignore file instead" -- Load ignore map if specified ignoreMap <- case opts.ignoreFile of @@ -118,36 +178,3 @@ runLint opts = do if null unignoredViolations then pure ExitSuccess else pure (ExitFailure 1) - --- | Discover baseline swagger-v*.json files in a directory. --- Only includes OpenAPI 3.0 files (v5+). -discoverBaselineFiles :: FilePath -> IO [FilePath] -discoverBaselineFiles dir = do - exists <- doesFileExist (dir "swagger-v5.json") - if not exists - then pure [] - else do - entries <- listDirectory dir - let swaggerFiles = - List.sort - [ dir e - | e <- entries, - "swagger-v" `List.isPrefixOf` e, - ".json" `List.isSuffixOf` e, - isOpenAPI3File e - ] - pure swaggerFiles - --- | Check if a swagger filename is v5 or higher (OpenAPI 3.0). -isOpenAPI3File :: String -> Bool -isOpenAPI3File name = - case extractVersionNum name of - Just n -> n >= 5 - Nothing -> False - --- | Extract the version number from a filename like "swagger-v5.json". -extractVersionNum :: String -> Maybe Int -extractVersionNum name = do - rest <- List.stripPrefix "swagger-v" name - let numStr = takeWhile (/= '.') rest - readMaybe numStr diff --git a/tools/lint-openapi-regression/lint-openapi-regression.cabal b/tools/lint-openapi-regression/lint-openapi-regression.cabal index 065c5b77a10..33ef098ff2a 100644 --- a/tools/lint-openapi-regression/lint-openapi-regression.cabal +++ b/tools/lint-openapi-regression/lint-openapi-regression.cabal @@ -19,6 +19,7 @@ flag static library exposed-modules: + LintOpenAPI.Baseline LintOpenAPI.Compare LintOpenAPI.Ignore LintOpenAPI.Normalize @@ -79,6 +80,7 @@ library , aeson-pretty , bytestring , containers + , filepath , imports , text , vector @@ -139,7 +141,6 @@ executable lint-openapi-regression build-depends: base , containers - , filepath , imports , lint-openapi-regression , optparse-applicative @@ -154,6 +155,7 @@ test-suite lint-openapi-regression-tests type: exitcode-stdio-1.0 main-is: Spec.hs other-modules: + LintOpenAPI.BaselineSpec LintOpenAPI.CompareSpec LintOpenAPI.IgnoreSpec LintOpenAPI.NormalizeSpec diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Baseline.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Baseline.hs new file mode 100644 index 00000000000..6c233dad822 --- /dev/null +++ b/tools/lint-openapi-regression/src/LintOpenAPI/Baseline.hs @@ -0,0 +1,66 @@ +module LintOpenAPI.Baseline + ( discoverBaselineFiles, + selectBaselineFile, + expectVersion, + ) +where + +import Imports +import LintOpenAPI.Types (OpenAPISpec (..)) +import System.FilePath (takeFileName, ()) + +-- | Discover baseline swagger-v*.json files in a directory. +-- Only includes OpenAPI 3.0 files (v5+). +discoverBaselineFiles :: FilePath -> IO [FilePath] +discoverBaselineFiles dir = do + exists <- doesFileExist (dir "swagger-v5.json") + if not exists + then pure [] + else do + entries <- listDirectory dir + let swaggerFiles = + sort + [ dir e + | e <- entries, + "swagger-v" `isPrefixOf` e, + ".json" `isSuffixOf` e, + isOpenAPI3File e + ] + pure swaggerFiles + +-- | Check if a swagger filename is v5 or higher (OpenAPI 3.0). +isOpenAPI3File :: String -> Bool +isOpenAPI3File name = + case extractVersionNum name of + Just n -> n >= 5 + Nothing -> False + +-- | Extract the version number from a filename like "swagger-v5.json". +extractVersionNum :: String -> Maybe Int +extractVersionNum name = do + rest <- stripPrefix "swagger-v" name + let numStr = takeWhile (/= '.') rest + readMaybe numStr + +-- | Pick the saved baseline file for a specific version among discovered baselines. +selectBaselineFile :: Int -> [FilePath] -> Either String FilePath +selectBaselineFile n files = + case filter (\fp -> let name = takeFileName fp in name == expectedName && isOpenAPI3File name) files of + (fp : _) -> Right fp + [] -> + Left + $ "No saved baseline for version v" + <> show n + <> ": " + <> expectedName + <> " not found (only OpenAPI 3 baselines, v5+, are supported)" + where + expectedName = "swagger-v" <> show n <> ".json" + +-- | Check that a spec carries the expected version (parsed from servers[0].url). +expectVersion :: Int -> OpenAPISpec -> Either String () +expectVersion n spec = case spec.version of + Nothing -> Left "spec has no version (servers[0].url)" + Just m + | m == n -> Right () + | otherwise -> Left $ "expected v" <> show n <> ", found v" <> show m diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Parse.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Parse.hs index 9ec34ded694..dca9d03255c 100644 --- a/tools/lint-openapi-regression/src/LintOpenAPI/Parse.hs +++ b/tools/lint-openapi-regression/src/LintOpenAPI/Parse.hs @@ -82,8 +82,8 @@ extractPathMethods schemas path = \case [ (rk, ri) | (methodKey, methodVal) <- KM.toList methodsObj, let methodText = Key.toText methodKey, - Just rk <- [routeKeyFromPath path methodText], - let ri = extractRouteInfo schemas methodVal + let ri = extractRouteInfo schemas methodVal, + Just rk <- [routeKeyFromPath path methodText] ] _ -> [] @@ -122,10 +122,10 @@ extractParams op = case KM.lookup "parameters" op of | Object p <- Vector.toList arr, String loc <- [fromMaybe Null (KM.lookup "in" p)], loc == "query", - String name <- [fromMaybe Null (KM.lookup "name" p)], let isReq = case KM.lookup "required" p of Just (Bool True) -> True - _ -> False + _ -> False, + String name <- [fromMaybe Null (KM.lookup "name" p)] ] in ( Set.fromList (map fst queryPs), Set.fromList [n | (n, True) <- queryPs] diff --git a/tools/lint-openapi-regression/test/LintOpenAPI/BaselineSpec.hs b/tools/lint-openapi-regression/test/LintOpenAPI/BaselineSpec.hs new file mode 100644 index 00000000000..cad0facc013 --- /dev/null +++ b/tools/lint-openapi-regression/test/LintOpenAPI/BaselineSpec.hs @@ -0,0 +1,40 @@ +module LintOpenAPI.BaselineSpec (spec) where + +import Data.Map.Strict qualified as Map +import Imports +import LintOpenAPI.Baseline +import LintOpenAPI.Types +import Test.Hspec + +spec :: Spec +spec = describe "LintOpenAPI.Baseline" $ do + describe "selectBaselineFile" $ do + let files = ["dir/swagger-v15.json", "dir/swagger-v16.json", "dir/swagger-v18.json"] + + it "picks the baseline matching the requested version" $ do + selectBaselineFile 16 files `shouldBe` Right "dir/swagger-v16.json" + + it "fails for a version with no baseline file" $ do + selectBaselineFile 17 files + `shouldBe` Left + "No saved baseline for version v17: swagger-v17.json not found (only OpenAPI 3 baselines, v5+, are supported)" + + it "fails for an OpenAPI 2 filename (v3) even if present" $ do + selectBaselineFile 3 ("dir/swagger-v3.json" : files) + `shouldBe` Left + "No saved baseline for version v3: swagger-v3.json not found (only OpenAPI 3 baselines, v5+, are supported)" + + it "fails when there are no baselines at all" $ do + selectBaselineFile 5 [] `shouldSatisfy` isLeft + + describe "expectVersion" $ do + let specWith v = OpenAPISpec {version = v, routes = Map.empty} + + it "succeeds on a matching version" $ do + expectVersion 16 (specWith (Just 16)) `shouldBe` Right () + + it "fails on a version mismatch" $ do + expectVersion 15 (specWith (Just 16)) `shouldBe` Left "expected v15, found v16" + + it "fails when the spec has no version" $ do + expectVersion 16 (specWith Nothing) `shouldBe` Left "spec has no version (servers[0].url)" From 00179db3af2cfbb4e1de25e1a4efc31d8868d8c4 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Mon, 12 Oct 2026 17:59:50 +0200 Subject: [PATCH 03/10] fix: per version API check --- .lint-ignore.json | 335 ------------------ Makefile | 14 +- .../src/developer/developer/api-versioning.md | 4 +- .../src/Wire/API/Routes/Public/Swagger.hs | 49 ++- services/brig/src/Brig/API/Public.hs | 71 ++-- services/brig/src/Brig/API/Public/Swagger.hs | 18 - tools/lint-openapi-regression/README.md | 74 ++-- tools/lint-openapi-regression/default.nix | 11 +- tools/lint-openapi-regression/exe/Main.hs | 231 ++++++------ .../lint-openapi-regression.cabal | 10 +- .../src/LintOpenAPI/Baseline.hs | 1 + .../src/LintOpenAPI/Fetch.hs | 30 ++ .../src/LintOpenAPI/Ignore.hs | 61 ++-- .../test/LintOpenAPI/IgnoreSpec.hs | 78 ++-- 14 files changed, 370 insertions(+), 617 deletions(-) delete mode 100644 .lint-ignore.json create mode 100644 tools/lint-openapi-regression/src/LintOpenAPI/Fetch.hs diff --git a/.lint-ignore.json b/.lint-ignore.json deleted file mode 100644 index 33742201b56..00000000000 --- a/.lint-ignore.json +++ /dev/null @@ -1,335 +0,0 @@ -{ - "v10": [ - "add-bot", - "add-members-to-conversation", - "create-conversation-code-unqualified", - "create-group-conversation", - "create-one-to-one-conversation", - "create-self-conversation", - "get-conversation", - "get-mls-self-conversation", - "get-one-to-one-mls-conversation", - "get-user-clients-qualified", - "join-conversation-by-code-unqualified", - "mls-commit-bundle", - "mls-message", - "put_CellsConfig", - "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" - ], - "v11": [ - "add-bot", - "add-members-to-conversation", - "create-conversation-code-unqualified", - "create-group-conversation", - "create-one-to-one-conversation", - "create-self-conversation", - "get-conversation", - "get-mls-self-conversation", - "get-one-to-one-mls-conversation", - "get-user-clients-qualified", - "join-conversation-by-code-unqualified", - "mls-commit-bundle", - "mls-message", - "put_CellsConfig", - "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" - ], - "v12": [ - "add-bot", - "add-members-to-conversation", - "create-app", - "create-conversation-code-unqualified", - "create-group-conversation", - "create-one-to-one-conversation", - "create-self-conversation", - "get-conversation", - "get-mls-self-conversation", - "get-one-to-one-mls-conversation", - "get-user-clients-qualified", - "join-conversation-by-code-unqualified", - "mls-commit-bundle", - "mls-message", - "put_CellsConfig", - "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" - ], - "v13": [ - "add-bot", - "add-members-to-conversation", - "create-app", - "create-conversation-code-unqualified", - "create-group-conversation", - "create-one-to-one-conversation", - "create-self-conversation", - "get-conversation", - "get-mls-self-conversation", - "get-one-to-one-mls-conversation", - "get-user-clients-qualified", - "join-conversation-by-code-unqualified", - "put_CellsConfig", - "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" - ], - "v14": [ - "add-bot", - "add-members-to-conversation", - "create-app", - "create-conversation-code-unqualified", - "create-group-conversation", - "create-one-to-one-conversation", - "create-self-conversation", - "get-app", - "get-conversation", - "get-mls-self-conversation", - "get-one-to-one-mls-conversation", - "get-user-clients-qualified", - "join-conversation-by-code-unqualified", - "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" - ], - "v15": [ - "get-app", - "get-user-clients-qualified" - ], - "v5": [ - "DELETE /conversations/{_}/bots/{_}", - "DELETE /conversations/{_}/code", - "DELETE /conversations/{_}/{_}/members/{_}/{_}", - "DELETE /oauth/applications/{_}", - "DELETE /self/phone", - "GET /", - "GET /bot/client", - "GET /calls/config", - "GET /clients/{_}", - "GET /clients/{_}/capabilities", - "GET /conversations/one2one/{_}/{_}", - "GET /conversations/{_}/self", - "GET /providers/{_}/services/{_}", - "GET /sso/initiate-login/{_}", - "GET /users/{_}/{_}/clients", - "HEAD /users/handles/{_}", - "POST /activate/send", - "POST /assets", - "POST /bot/assets", - "POST /bot/messages", - "POST /bot/users/prekeys", - "POST /broadcast/otr/messages", - "POST /broadcast/proteus/messages", - "POST /clients", - "POST /connections/{_}/{_}", - "POST /conversations", - "POST /conversations/join", - "POST /conversations/one2one", - "POST /conversations/{_}/bots", - "POST /conversations/{_}/code", - "POST /conversations/{_}/otr/messages", - "POST /conversations/{_}/{_}/members", - "POST /conversations/{_}/{_}/proteus/messages", - "POST /login/send", - "POST /mls/commit-bundles", - "POST /mls/messages", - "POST /onboarding/v3", - "POST /password-reset/{_}", - "POST /provider/assets", - "POST /push/tokens", - "POST /register", - "POST /teams/{_}/invitations", - "POST /teams/{_}/legalhold/{_}", - "POST /users/handles", - "PUT /clients/{_}", - "PUT /connections/{_}/{_}", - "PUT /conversations/{_}", - "PUT /conversations/{_}/members/{_}", - "PUT /conversations/{_}/message-timer", - "PUT /conversations/{_}/name", - "PUT /conversations/{_}/receipt-mode", - "PUT /conversations/{_}/self", - "PUT /conversations/{_}/{_}/access", - "PUT /conversations/{_}/{_}/message-timer", - "PUT /conversations/{_}/{_}/name", - "PUT /conversations/{_}/{_}/protocol", - "PUT /conversations/{_}/{_}/receipt-mode", - "PUT /self/phone" - ], - "v6": [ - "DELETE /conversations/{_}/bots/{_}", - "DELETE /conversations/{_}/code", - "DELETE /conversations/{_}/{_}/members/{_}/{_}", - "DELETE /oauth/applications/{_}", - "GET /", - "GET /bot/client", - "GET /calls/config", - "GET /clients/{_}", - "GET /clients/{_}/capabilities", - "GET /conversations/one2one/{_}/{_}", - "GET /conversations/{_}/self", - "GET /providers/{_}/services/{_}", - "GET /sso/initiate-login/{_}", - "GET /users/{_}/{_}/clients", - "HEAD /users/handles/{_}", - "POST /assets", - "POST /bot/assets", - "POST /clients", - "POST /conversations", - "POST /conversations/join", - "POST /conversations/one2one", - "POST /conversations/{_}/bots", - "POST /conversations/{_}/code", - "POST /conversations/{_}/{_}/members", - "POST /mls/commit-bundles", - "POST /mls/messages", - "POST /onboarding/v3", - "POST /password-reset/{_}", - "POST /provider/assets", - "POST /register", - "POST /teams/{_}/invitations", - "POST /teams/{_}/legalhold/{_}", - "POST /users/handles", - "PUT /clients/{_}", - "PUT /conversations/{_}", - "PUT /conversations/{_}/members/{_}", - "PUT /conversations/{_}/message-timer", - "PUT /conversations/{_}/name", - "PUT /conversations/{_}/receipt-mode", - "PUT /conversations/{_}/self", - "PUT /conversations/{_}/{_}/access", - "PUT /conversations/{_}/{_}/message-timer", - "PUT /conversations/{_}/{_}/name", - "PUT /conversations/{_}/{_}/protocol", - "PUT /conversations/{_}/{_}/receipt-mode" - ], - "v7": [ - "add-bot", - "add-members-to-conversation", - "assets-upload", - "assets-upload-v3_bot", - "assets-upload-v3_provider", - "auth-req", - "create-conversation-code-unqualified", - "create-group-conversation", - "get-calls-config", - "get-conversation-self-unqualified", - "get-user-clients-qualified", - "join-conversation-by-code-unqualified", - "mls-commit-bundle", - "mls-message", - "onboarding", - "post-password-reset-key-deprecated", - "register", - "remove-bot", - "remove-code-unqualified", - "remove-member", - "update-client@v7", - "update-conversation-access", - "update-conversation-message-timer", - "update-conversation-message-timer-unqualified", - "update-conversation-name", - "update-conversation-name-deprecated", - "update-conversation-name-unqualified", - "update-conversation-protocol", - "update-conversation-receipt-mode", - "update-conversation-receipt-mode-unqualified", - "update-conversation-self-unqualified" - ], - "v8": [ - "add-bot", - "add-members-to-conversation", - "auth-req", - "create-conversation-code-unqualified", - "create-group-conversation", - "create-one-to-one-conversation", - "create-self-conversation", - "get-conversation", - "get-domain-registration", - "get-mls-self-conversation", - "get-one-to-one-mls-conversation", - "get-user-clients-qualified", - "join-conversation-by-code-unqualified", - "mls-commit-bundle", - "mls-message", - "put_CellsConfig", - "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", - "update-domain-redirect" - ], - "v9": [ - "add-bot", - "add-members-to-conversation", - "create-conversation-code-unqualified", - "create-group-conversation@v9", - "create-one-to-one-conversation", - "create-self-conversation", - "get-conversation", - "get-domain-registration@v9", - "get-mls-self-conversation", - "get-one-to-one-mls-conversation", - "get-user-clients-qualified", - "join-conversation-by-code-unqualified", - "mls-commit-bundle", - "mls-message", - "put_CellsConfig", - "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", - "update-domain-redirect@v9" - ] -} \ No newline at end of file diff --git a/Makefile b/Makefile index 8ee6798f466..760bd6a4f67 100644 --- a/Makefile +++ b/Makefile @@ -783,16 +783,10 @@ openapi-validate: BRIG_URL ?= http://localhost:8082 -# Usage: make openapi-lint-regression VERSION=16 [BRIG_URL=] +# Usage: make openapi-lint-regression [VERSION=] [BRIG_URL=] [UPDATE=1] .PHONY: openapi-lint-regression openapi-lint-regression: - @if [ -z "$(VERSION)" ]; then \ - echo "usage: make openapi-lint-regression VERSION= [BRIG_URL=]"; \ - exit 1; \ + @if [ -n "$(VERSION)" ] && ! echo "$(VERSION)" | grep -qE '^[0-9]+$$'; then \ + echo "VERSION must be a positive integer" >&2; exit 1; \ fi - @case "$(VERSION)" in *[!0-9]*) echo "VERSION must be a positive integer" >&2; exit 1;; esac - curl -fsS "$(BRIG_URL)/v$(VERSION)/api/swagger.json" > /tmp/openapi-actual-v$(VERSION).json - cabal run lint-openapi-regression -- \ - --baseline-dir services/brig/docs \ - --api-version "$(VERSION)" \ - /tmp/openapi-actual-v$(VERSION).json + $(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,)) diff --git a/docs/src/developer/developer/api-versioning.md b/docs/src/developer/developer/api-versioning.md index 0df965acbb7..99c50b04f44 100644 --- a/docs/src/developer/developer/api-versioning.md +++ b/docs/src/developer/developer/api-versioning.md @@ -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`. diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs b/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs index 9a822b51711..0e4eaaa84f8 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs @@ -15,14 +15,12 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . --- | 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, ) where @@ -31,7 +29,8 @@ import Control.Lens ((.~)) import Data.OpenApi qualified as S 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) @@ -54,22 +53,36 @@ 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 diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index e5cc1fdec25..a95af09d2b7 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -55,7 +55,7 @@ import Brig.User.Client qualified as API import Cassandra qualified as C import Cassandra qualified as Data import Control.Error hiding (bool, note) -import Control.Lens ((?~)) +import Control.Lens ((.~), (?~)) import Control.Monad.Except import Data.Aeson import Data.ByteString (fromStrict) @@ -95,6 +95,7 @@ import Polysemy.Input (Input) import Polysemy.TinyLog (TinyLog) import Servant hiding (Handler, JSON, addHeader, respond) import Servant qualified +import Servant.OpenApi (HasOpenApi) import Servant.OpenApi.Internal.Orphans () import Servant.Swagger.UI import System.Logger.Class qualified as Log @@ -111,6 +112,7 @@ import Wire.API.Federation.Error import Wire.API.Federation.Version qualified as Fed import Wire.API.Pagination import Wire.API.Properties qualified as Public +import Wire.API.Routes.API (SpecialisedAPIRoutes) import Wire.API.Routes.Bearer import Wire.API.Routes.Internal.Brig qualified as BrigInternalAPI import Wire.API.Routes.Internal.Cannon qualified as CannonInternalAPI @@ -122,6 +124,12 @@ import Wire.API.Routes.MultiTablePaging qualified as Public import Wire.API.Routes.Named (Named (Named)) import Wire.API.Routes.Public.Brig import Wire.API.Routes.Public.Brig.DomainVerification +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 import Wire.API.Routes.Public.Util import Wire.API.Routes.Version @@ -233,6 +241,29 @@ internalEndpointsSwaggerDocsAPIs = :<|> internalEndpointsSwaggerDocsAPI @"spar" "spar" 9088 SparInternalAPI.swaggerDoc :<|> internalEndpointsSwaggerDocsAPI @"gundeck" "gundeck" 9086 GundeckInternalAPI.swaggerDoc +-- | Live-rendered OpenAPI of all public services restricted to the version +-- corresponding to @v@, adjusted like the frozen snapshots used to be: server +-- URL set to @/vN@, title set, and servant's default responses cleaned up. +liveSwagger :: + 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 -> + Servant.Server SwaggerDocsAPIBase +liveSwagger _ vn = + swaggerSchemaUIServer $ + allServicesSwagger @v + & S.info . S.title .~ "Wire-Server API" + & S.servers .~ [S.Server ("/" <> toUrlPiece vn) Nothing mempty] + & cleanupSwagger + -- | Serves Swagger docs for public endpoints -- -- Dual to `internalEndpointsSwaggerDocsAPI`. @@ -241,25 +272,25 @@ versionedSwaggerDocsAPI (Just (VersionNumber V19)) = swaggerSchemaUIServer $ devVersionSwagger & S.info . S.description ?~ $((unTypeCode . embedText) =<< makeRelativeToProject "docs/swagger.md") -versionedSwaggerDocsAPI (Just (VersionNumber V18)) = swaggerPregenUIServer $(pregenSwagger V18) -versionedSwaggerDocsAPI (Just (VersionNumber V17)) = swaggerPregenUIServer $(pregenSwagger V17) -versionedSwaggerDocsAPI (Just (VersionNumber V16)) = swaggerPregenUIServer $(pregenSwagger V16) -versionedSwaggerDocsAPI (Just (VersionNumber V15)) = swaggerPregenUIServer $(pregenSwagger V15) -versionedSwaggerDocsAPI (Just (VersionNumber V14)) = swaggerPregenUIServer $(pregenSwagger V14) -versionedSwaggerDocsAPI (Just (VersionNumber V13)) = swaggerPregenUIServer $(pregenSwagger V13) -versionedSwaggerDocsAPI (Just (VersionNumber V12)) = swaggerPregenUIServer $(pregenSwagger V12) -versionedSwaggerDocsAPI (Just (VersionNumber V11)) = swaggerPregenUIServer $(pregenSwagger V11) -versionedSwaggerDocsAPI (Just (VersionNumber V10)) = swaggerPregenUIServer $(pregenSwagger V10) -versionedSwaggerDocsAPI (Just (VersionNumber V9)) = swaggerPregenUIServer $(pregenSwagger V9) -versionedSwaggerDocsAPI (Just (VersionNumber V8)) = swaggerPregenUIServer $(pregenSwagger V8) -versionedSwaggerDocsAPI (Just (VersionNumber V7)) = swaggerPregenUIServer $(pregenSwagger V7) -versionedSwaggerDocsAPI (Just (VersionNumber V6)) = swaggerPregenUIServer $(pregenSwagger V6) -versionedSwaggerDocsAPI (Just (VersionNumber V5)) = swaggerPregenUIServer $(pregenSwagger V5) -versionedSwaggerDocsAPI (Just (VersionNumber V4)) = swaggerPregenUIServer $(pregenSwagger V4) -versionedSwaggerDocsAPI (Just (VersionNumber V3)) = swaggerPregenUIServer $(pregenSwagger V3) -versionedSwaggerDocsAPI (Just (VersionNumber V2)) = swaggerPregenUIServer $(pregenSwagger V2) -versionedSwaggerDocsAPI (Just (VersionNumber V1)) = swaggerPregenUIServer $(pregenSwagger V1) -versionedSwaggerDocsAPI (Just (VersionNumber V0)) = swaggerPregenUIServer $(pregenSwagger V0) +versionedSwaggerDocsAPI (Just vn@(VersionNumber V18)) = liveSwagger (Proxy @'V18) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V17)) = liveSwagger (Proxy @'V17) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V16)) = liveSwagger (Proxy @'V16) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V15)) = liveSwagger (Proxy @'V15) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V14)) = liveSwagger (Proxy @'V14) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V13)) = liveSwagger (Proxy @'V13) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V12)) = liveSwagger (Proxy @'V12) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V11)) = liveSwagger (Proxy @'V11) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V10)) = liveSwagger (Proxy @'V10) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V9)) = liveSwagger (Proxy @'V9) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V8)) = liveSwagger (Proxy @'V8) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V7)) = liveSwagger (Proxy @'V7) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V6)) = liveSwagger (Proxy @'V6) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V5)) = liveSwagger (Proxy @'V5) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V4)) = liveSwagger (Proxy @'V4) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V3)) = liveSwagger (Proxy @'V3) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V2)) = liveSwagger (Proxy @'V2) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V1)) = liveSwagger (Proxy @'V1) vn +versionedSwaggerDocsAPI (Just vn@(VersionNumber V0)) = liveSwagger (Proxy @'V0) vn versionedSwaggerDocsAPI Nothing = tocPage where -- Renders and returns a table-of-contents page diff --git a/services/brig/src/Brig/API/Public/Swagger.hs b/services/brig/src/Brig/API/Public/Swagger.hs index f0fe1a88f14..1ed6e44e5d3 100644 --- a/services/brig/src/Brig/API/Public/Swagger.hs +++ b/services/brig/src/Brig/API/Public/Swagger.hs @@ -23,8 +23,6 @@ module Brig.API.Public.Swagger ServiceSwaggerDocsAPIBase, DocsAPI, FederationSwaggerDocsAPI, - pregenSwagger, - swaggerPregenUIServer, eventNotificationSchemas, adjustSwaggerForInternalEndpoint, adjustSwaggerForFederationEndpoints, @@ -33,18 +31,14 @@ module Brig.API.Public.Swagger where import Control.Lens -import Data.Aeson qualified as A -import Data.FileEmbed import Data.HashMap.Strict.InsOrd qualified as HM import Data.HashSet.InsOrd qualified as InsOrdSet import Data.Kind qualified as Kind import Data.OpenApi qualified as S import Data.OpenApi.Declare qualified as S import Data.Text qualified as T -import FileEmbedLzma import GHC.TypeLits import Imports hiding (head) -import Language.Haskell.TH import Network.Socket import Servant import Servant.OpenApi.Internal.Orphans () @@ -90,18 +84,6 @@ type DocsAPI = :<|> InternalEndpointsSwaggerDocsAPI :<|> FederationSwaggerDocsAPI -pregenSwagger :: Version -> Q Exp -pregenSwagger v = - (unTypeCode . embedLazyByteString) - =<< makeRelativeToProject - ("docs/swagger-v" <> T.unpack (toUrlPiece (VersionNumber v)) <> ".json") - -swaggerPregenUIServer :: LByteString -> Server SwaggerDocsAPIBase -swaggerPregenUIServer = - swaggerSchemaUIServer - . fromMaybe A.Null - . A.decode - adjustSwaggerForInternalEndpoint :: String -> PortNumber -> S.OpenApi -> S.OpenApi adjustSwaggerForInternalEndpoint service examplePort swagger = swagger diff --git a/tools/lint-openapi-regression/README.md b/tools/lint-openapi-regression/README.md index beecdf96959..b75eb1693a2 100644 --- a/tools/lint-openapi-regression/README.md +++ b/tools/lint-openapi-regression/README.md @@ -1,8 +1,14 @@ # lint-openapi-regression -Detects backward-incompatible changes in OpenAPI JSON files by comparing a -candidate spec against historical baseline versions (e.g. `swagger-v5.json`, -`swagger-v6.json`). +Detects backward-incompatible changes in OpenAPI specs. Two modes: + +- **Single-version drift check** (`--api-version N`): compares the saved + baseline snapshot `swagger-vN.json` against the spec that a running brig + *live-renders* for version N (`GET /vN/api/swagger.json`). This catches + regressions of old API versions that snapshots alone cannot see. +- **All-versions mode** (no `--api-version`): for every baseline snapshot + (v5+), fetches the live spec of that same version and compares + snapshot-vs-live per version. ## What it checks @@ -39,7 +45,7 @@ cabal test lint-openapi-regression-tests \ ## CLI usage ``` -lint-openapi-regression [OPTIONS] INPUT_FILE +lint-openapi-regression [OPTIONS] [INPUT_FILE] ``` ### Options @@ -47,64 +53,56 @@ lint-openapi-regression [OPTIONS] INPUT_FILE | Flag | Default | Description | |--------------------|------------------------|------------------------------------------------------| | `--baseline-dir` | `services/brig/docs` | Directory containing baseline `swagger-v*.json` files | -| `--update` | off | Update the ignore file with new breaking changes | -| `--api-version N` | (none) | Compare only against the saved baseline of this version instead of all baselines | - -### Examples - -Check a new spec against all baselines in the default directory: - -```bash -lint-openapi-regression services/brig/docs/swagger.json -``` +| `--brig-url` | `http://localhost:8082`| Base URL of a running brig for live spec fetching (use `https:` for any non-local brig; `--update` should only run against a trusted brig since its response shapes the committed ignore files) | +| `--api-version N` | (none) | Check only version N (baseline vN vs live vN spec) | +| `INPUT_FILE` | (none) | Offline candidate spec (only with `--api-version`) | +| `--ignore FILE` | (none) | Flat JSON ignore file (only with `--api-version`) | +| `--ignore-dir DIR` | `.lint-ignore` | Directory for per-version ignore files `vN.json` | +| `--update` | off | Write the current violations to the ignore file(s) | -Check against baselines in a custom directory: +There is no default/implicit ignore file: violations are only suppressed via an +explicit `--ignore` file. `make openapi-lint-regression` passes +`.lint-ignore/vN.json` when it exists. -```bash -lint-openapi-regression --baseline-dir path/to/baselines new-spec.json -``` +### Examples -Use an ignore file to suppress known violations: +Check every baseline version against the live specs served by brig: ```bash -lint-openapi-regression --ignore .lint-ignore.json services/brig/docs/swagger.json +lint-openapi-regression --brig-url http://localhost:8082 ``` -Auto-update the ignore file with any new violations found: +Check one version, applying its ignore file if present (via +`make openapi-lint-regression VERSION=15`): ```bash -lint-openapi-regression --ignore .lint-ignore.json --update services/brig/docs/swagger.json +lint-openapi-regression --brig-url http://localhost:8082 \ + --api-version 15 --ignore .lint-ignore/v15.json ``` -Fetch the OpenAPI served for a specific version from a running brig and compare -it against the saved baseline of that same version (frozen-version drift check; -via `make openapi-lint-regression`): +Check an offline candidate file instead of fetching from brig: ```bash -make openapi-lint-regression VERSION=16 +lint-openapi-regression --api-version 15 new-spec.json ``` -Note: `.lint-ignore.json`-style ignore files are keyed by baseline version and -record intentional changes *since* each version, so combining `--api-version` -with such a file masks frozen-version drift. Use a separate ignore file if you -need to suppress known drift. - ## Exit codes | Code | Meaning | |------|--------------------------------------------| | 0 | No breaking changes (or all are ignored) | | 1 | Breaking changes detected | -| 2 | Input error (file not found, parse failure, version mismatch, missing baseline) | +| 2 | Input error (file not found, parse failure, fetch failure, version mismatch, missing baseline) | ## Ignore file format -The ignore file is a JSON object mapping baseline version keys to sets of route -identifiers (either `operationId` or rendered route like `GET /users/{_}`): +Ignore files are flat, per-version JSON arrays of route identifiers — either +an `operationId` or a rendered route like `GET /users/{_}`: ```json -{ - "v5": ["getUser", "POST /teams"], - "v6": ["GET /users/{_}"] -} +[ "add-bot", "GET /users/{_}" ] ``` + +`--update` writes the **full current violation set** of each checked version +(ignored and unignored), so stale entries are pruned automatically: if a +suppressed violation disappears, the next `--update` run removes it. diff --git a/tools/lint-openapi-regression/default.nix b/tools/lint-openapi-regression/default.nix index 612e786ee9a..10eb90d07de 100644 --- a/tools/lint-openapi-regression/default.nix +++ b/tools/lint-openapi-regression/default.nix @@ -10,10 +10,14 @@ , containers , filepath , hspec +, http-client +, http-client-tls +, http-types , imports , lib , optparse-applicative , text +, unliftio , vector }: mkDerivation { @@ -28,13 +32,18 @@ mkDerivation { bytestring containers filepath + http-client + http-client-tls + http-types imports text + unliftio vector ]; executableHaskellDepends = [ + aeson base - containers + filepath imports optparse-applicative text diff --git a/tools/lint-openapi-regression/exe/Main.hs b/tools/lint-openapi-regression/exe/Main.hs index 2d04695f476..76a569e7f1d 100644 --- a/tools/lint-openapi-regression/exe/Main.hs +++ b/tools/lint-openapi-regression/exe/Main.hs @@ -1,26 +1,31 @@ module Main (main) where +import Data.Aeson (decode') import Data.List qualified as List -import Data.Map.Strict qualified as Map import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO import Imports -import LintOpenAPI.Baseline (discoverBaselineFiles, expectVersion, selectBaselineFile) +import LintOpenAPI.Baseline (discoverBaselineFiles, expectVersion, extractVersionNum, selectBaselineFile) import LintOpenAPI.Compare (compareSpecs) -import LintOpenAPI.Ignore (isIgnored, readIgnoreFile, updateIgnoreMap, writeIgnoreFile) -import LintOpenAPI.Parse (parseOpenAPIFile) +import LintOpenAPI.Fetch (fetchVersionSpec) +import LintOpenAPI.Ignore (IgnoreSet, collectIgnoreSet, isIgnored, readIgnoreFile, writeIgnoreFile) +import LintOpenAPI.Parse (parseOpenAPIFile, parseOpenAPIValue) import LintOpenAPI.Report (formatViolations) -import LintOpenAPI.Types (OpenAPISpec (..)) +import LintOpenAPI.Types (OpenAPISpec (..), ViolationContext (..)) import Options.Applicative import System.Exit (ExitCode (..), exitWith) +import System.FilePath (takeDirectory, takeFileName, ()) import System.IO qualified as IO -- | CLI configuration. data Options = Options { baselineDir :: FilePath, - inputFile :: FilePath, + -- | Optional offline candidate spec; otherwise fetched from brig. + inputFile :: Maybe FilePath, ignoreFile :: Maybe FilePath, updateIgnore :: Bool, + brigUrl :: Text, + ignoreDir :: FilePath, apiVersion :: Maybe Int } @@ -35,27 +40,43 @@ optionsParser = <> showDefault <> help "Directory containing baseline swagger-v*.json files" ) - <*> strArgument - ( metavar "INPUT_FILE" - <> help "Path to the new OpenAPI JSON file to check" + <*> optional + ( strArgument + ( metavar "INPUT_FILE" + <> help "Path to an OpenAPI JSON file to check (only with --api-version); otherwise specs are fetched from brig" + ) ) <*> optional ( strOption ( long "ignore" <> metavar "FILE" - <> help "Path to the JSON ignore file" + <> help "Path to a flat JSON ignore file (only with --api-version)" ) ) <*> switch ( long "update" - <> help "Update the ignore file with unignored breaking changes" + <> help "Write the current violations to the ignore file(s)" + ) + <*> strOption + ( long "brig-url" + <> metavar "URL" + <> value "http://localhost:8082" + <> showDefault + <> help "Base URL of a running brig used to fetch live per-version specs" + ) + <*> strOption + ( long "ignore-dir" + <> metavar "DIR" + <> value ".lint-ignore" + <> showDefault + <> help "Directory holding per-version ignore files vX.json (used by --update without --ignore)" ) <*> optional ( option auto ( long "api-version" <> metavar "N" - <> help "Compare only against the saved baseline of this version instead of all baselines" + <> help "Check only version N: baseline vX vs the spec served for vN (or INPUT_FILE)" ) ) @@ -65,7 +86,9 @@ optionsParserInfo = info (optionsParser <**> helper) ( fullDesc - <> progDesc "Check an OpenAPI JSON file for backward-incompatible changes" + <> progDesc + "Check OpenAPI specs for backward-incompatible changes: snapshot-vs-live for \ + \one version (--api-version) or for every baseline version" <> header "lint-openapi-regression - OpenAPI backward-compatibility linter" ) @@ -75,106 +98,96 @@ main = do result <- runLint opts exitWith result --- | Main lint workflow. Returns appropriate exit code. -runLint :: Options -> IO ExitCode -runLint opts = do - -- Parse the input file - inputExists <- doesFileExist opts.inputFile - unless inputExists $ do - Text.IO.hPutStrLn IO.stderr $ "Error: Input file not found: " <> Text.pack opts.inputFile - exitWith (ExitFailure 2) +failWith :: Text -> IO a +failWith msg = do + Text.IO.hPutStrLn IO.stderr ("Error: " <> msg) + exitWith (ExitFailure 2) - inputResult <- parseOpenAPIFile opts.inputFile - candidate <- case inputResult of - Left err -> do - Text.IO.hPutStrLn IO.stderr $ "Error parsing input file: " <> Text.pack err - exitWith (ExitFailure 2) - Right spec -> pure spec +-- | Fetch the live spec for version @n@ from brig and parse it. +fetchAndParse :: Text -> Int -> IO OpenAPISpec +fetchAndParse brigUrl n = do + fetched <- fetchVersionSpec brigUrl n + body <- either (failWith . Text.pack) pure fetched + case decode' body of + Nothing -> failWith ("Failed to parse the spec fetched for v" <> Text.pack (show n)) + Just val -> either (failWith . Text.pack) pure (parseOpenAPIValue val) - -- Discover and parse baseline files - baselineFiles <- case opts.apiVersion of - Nothing -> do - files <- discoverBaselineFiles opts.baselineDir - when (null files) $ - Text.IO.hPutStrLn IO.stderr $ - "Warning: No baseline swagger-v*.json files found in " <> Text.pack opts.baselineDir - pure files - Just n -> do - files <- discoverBaselineFiles opts.baselineDir - case selectBaselineFile n files of - Left err -> do - Text.IO.hPutStrLn IO.stderr $ "Error: " <> Text.pack err - exitWith (ExitFailure 2) - Right fp -> pure [fp] +-- | Parse one baseline snapshot, failing with the file name in the message. +readBaseline :: FilePath -> IO OpenAPISpec +readBaseline fp = + parseOpenAPIFile fp + >>= either (failWith . Text.pack . (("Failed to parse " <> fp <> ": ") <>)) pure - -- When targeting a version, verify the candidate actually is that version - case opts.apiVersion of - Just n -> do - let candidateVersion = case candidate of OpenAPISpec {version = v} -> v - case candidateVersion of - Nothing -> do - Text.IO.hPutStrLn IO.stderr "Error: Candidate spec has no version (servers[0].url)" - exitWith (ExitFailure 2) - Just m - | m /= n -> do - Text.IO.hPutStrLn IO.stderr $ - "Error: Candidate spec is v" - <> Text.pack (show m) - <> " but --api-version v" - <> Text.pack (show n) - <> " was requested" - exitWith (ExitFailure 2) - | otherwise -> pure () - Nothing -> pure () - baselines <- forM baselineFiles $ \fp -> do - result <- parseOpenAPIFile fp - case result of - Left err -> case opts.apiVersion of - Just _ -> - Text.IO.hPutStrLn IO.stderr ("Error: Failed to parse " <> Text.pack fp <> ": " <> Text.pack err) - >> exitWith (ExitFailure 2) - Nothing -> do - Text.IO.hPutStrLn IO.stderr $ "Warning: Failed to parse " <> Text.pack fp <> ": " <> Text.pack err - pure Nothing - Right spec -> do - case opts.apiVersion of - Just n -> case expectVersion n spec of - Left err -> do - Text.IO.hPutStrLn IO.stderr $ - "Error: " <> Text.pack fp <> ": " <> Text.pack err - exitWith (ExitFailure 2) - Right () -> pure () - Nothing -> pure () - pure (Just spec) - - when (isJust opts.apiVersion) $ - forM_ opts.ignoreFile $ \f -> - Text.IO.hPutStrLn IO.stderr $ - "Warning: ignore entries are keyed by baseline version and record intentional " - <> "changes since that version, so they may mask frozen-version drift in " - <> Text.pack f - <> "; use a separate drift ignore file instead" - - -- Load ignore map if specified - ignoreMap <- case opts.ignoreFile of - Just f -> readIgnoreFile f - Nothing -> pure Map.empty - - let validBaselines = catMaybes baselines - allViolations = concatMap (`compareSpecs` candidate) validBaselines - (ignoredViolations, unignoredViolations) = List.partition (isIgnored ignoreMap) allViolations - report = formatViolations (length ignoredViolations) unignoredViolations +-- | Main lint workflow. Returns appropriate exit code. +runLint :: Options -> IO ExitCode +runLint opts = case opts.apiVersion of + Just n -> runSingleVersion opts n + Nothing -> runAllVersions opts - Text.IO.hPutStrLn IO.stderr report +-- | Single-version mode: baseline vN against INPUT_FILE or the live spec of vN. +runSingleVersion :: Options -> Int -> IO ExitCode +runSingleVersion opts n = do + candidate <- case opts.inputFile of + Just fp -> do + exists <- doesFileExist fp + unless exists $ + failWith ("Input file not found: " <> Text.pack fp) + result <- parseOpenAPIFile fp + either (failWith . Text.pack) pure result + Nothing -> fetchAndParse opts.brigUrl n + either (failWith . Text.pack) pure (expectVersion n candidate) + files <- discoverBaselineFiles opts.baselineDir + fp <- either (failWith . Text.pack) pure (selectBaselineFile n files) + baseline <- readBaseline fp + either (failWith . Text.pack) pure (expectVersion n baseline) + ignoreSet <- loadIgnoreSet opts + let violations = compareSpecs baseline candidate + (ignored, unignored) = List.partition (isIgnored ignoreSet) violations + Text.IO.hPutStrLn IO.stderr (formatViolations (length ignored) unignored) + when opts.updateIgnore $ + case opts.ignoreFile of + Just f -> writeIgnoreTarget f violations + Nothing -> writeIgnoreTarget (opts.ignoreDir ("v" <> show n <> ".json")) violations + pure (if null unignored then ExitSuccess else ExitFailure 1) +-- | All-versions mode: every baseline snapshot vs the live spec of that version. +runAllVersions :: Options -> IO ExitCode +runAllVersions opts = do + when (isJust opts.inputFile) $ + failWith "INPUT_FILE requires --api-version" + when (isJust opts.ignoreFile) $ + failWith "--ignore requires --api-version" + files <- discoverBaselineFiles opts.baselineDir + when (null files) $ + Text.IO.hPutStrLn IO.stderr $ + "Warning: No baseline swagger-v*.json files found in " <> Text.pack opts.baselineDir + perVersion <- forM files $ \fp -> do + baseline <- readBaseline fp + n <- case extractVersionNum (takeFileName fp) of + Just v -> pure v + Nothing -> failWith ("Cannot determine version from baseline file name: " <> Text.pack fp) + either (failWith . Text.pack) pure (expectVersion n baseline) + live <- fetchAndParse opts.brigUrl n + pure (n, compareSpecs baseline live) + let sorted = List.sortOn fst perVersion + allCtxs = concatMap snd sorted + Text.IO.hPutStrLn IO.stderr (formatViolations 0 allCtxs) when opts.updateIgnore $ do - case opts.ignoreFile of - Just f -> do - let newMap = updateIgnoreMap ignoreMap unignoredViolations - writeIgnoreFile f newMap - Text.IO.hPutStrLn IO.stderr $ "Updated ignore file: " <> Text.pack f - Nothing -> Text.IO.hPutStrLn IO.stderr "Warning: --update specified but no --ignore file provided." + createDirectoryIfMissing True opts.ignoreDir + forM_ sorted $ \(n, ctxs) -> + unless (null ctxs) $ + writeIgnoreTarget (opts.ignoreDir ("v" <> show n <> ".json")) ctxs + pure (if null allCtxs then ExitSuccess else ExitFailure 1) + +-- | Load the explicit @--ignore@ set. There is no implicit ignore file. +loadIgnoreSet :: Options -> IO IgnoreSet +loadIgnoreSet opts = case opts.ignoreFile of + Nothing -> pure mempty + Just f -> either (failWith . Text.pack) pure =<< readIgnoreFile f - if null unignoredViolations - then pure ExitSuccess - else pure (ExitFailure 1) +-- | Write the full current violation set of one version to a single file. +writeIgnoreTarget :: FilePath -> [ViolationContext] -> IO () +writeIgnoreTarget f ctxs = do + createDirectoryIfMissing True (takeDirectory f) + writeIgnoreFile f (collectIgnoreSet ctxs) + Text.IO.hPutStrLn IO.stderr ("Updated ignore file: " <> Text.pack f) diff --git a/tools/lint-openapi-regression/lint-openapi-regression.cabal b/tools/lint-openapi-regression/lint-openapi-regression.cabal index 33ef098ff2a..d87134fdb06 100644 --- a/tools/lint-openapi-regression/lint-openapi-regression.cabal +++ b/tools/lint-openapi-regression/lint-openapi-regression.cabal @@ -21,6 +21,7 @@ library exposed-modules: LintOpenAPI.Baseline LintOpenAPI.Compare + LintOpenAPI.Fetch LintOpenAPI.Ignore LintOpenAPI.Normalize LintOpenAPI.Parse @@ -81,8 +82,12 @@ library , bytestring , containers , filepath + , http-client + , http-client-tls + , http-types , imports , text + , unliftio , vector default-language: GHC2021 @@ -139,8 +144,9 @@ executable lint-openapi-regression -Wunused-packages build-depends: - base - , containers + aeson + , base + , filepath , imports , lint-openapi-regression , optparse-applicative diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Baseline.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Baseline.hs index 6c233dad822..09b0b27e419 100644 --- a/tools/lint-openapi-regression/src/LintOpenAPI/Baseline.hs +++ b/tools/lint-openapi-regression/src/LintOpenAPI/Baseline.hs @@ -1,5 +1,6 @@ module LintOpenAPI.Baseline ( discoverBaselineFiles, + extractVersionNum, selectBaselineFile, expectVersion, ) diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Fetch.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Fetch.hs new file mode 100644 index 00000000000..72bceacce09 --- /dev/null +++ b/tools/lint-openapi-regression/src/LintOpenAPI/Fetch.hs @@ -0,0 +1,30 @@ +module LintOpenAPI.Fetch (fetchVersionSpec) where + +import Data.Text qualified as Text +import Imports +import Network.HTTP.Client qualified as HC +import Network.HTTP.Client.TLS (tlsManagerSettings) +import Network.HTTP.Types.Status (statusCode) +import UnliftIO.Exception (try) + +-- | Fetch the OpenAPI spec that a running brig serves for API version @n@: +-- @GET {url}/v{n}/api/swagger.json@. Non-2xx responses yield @Left@. +fetchVersionSpec :: Text -> Int -> IO (Either String LByteString) +fetchVersionSpec url n = do + let fullUrl = url <> "/v" <> Text.pack (show n) <> "/api/swagger.json" + manager <- HC.newManager tlsManagerSettings + eResp <- + try (HC.parseRequest (Text.unpack fullUrl) >>= \req -> HC.httpLbs req {HC.redirectCount = 0} manager) :: + IO (Either HC.HttpException (HC.Response LByteString)) + pure $ case eResp of + Left err -> Left ("GET " <> Text.unpack fullUrl <> " failed: " <> show err) + Right resp + | statusCode (HC.responseStatus resp) `div` 100 == 2 -> + Right (HC.responseBody resp) + | otherwise -> + Left + ( "GET " + <> Text.unpack fullUrl + <> " failed: HTTP " + <> show (statusCode (HC.responseStatus resp)) + ) diff --git a/tools/lint-openapi-regression/src/LintOpenAPI/Ignore.hs b/tools/lint-openapi-regression/src/LintOpenAPI/Ignore.hs index 5603592b969..425ef158042 100644 --- a/tools/lint-openapi-regression/src/LintOpenAPI/Ignore.hs +++ b/tools/lint-openapi-regression/src/LintOpenAPI/Ignore.hs @@ -1,9 +1,9 @@ module LintOpenAPI.Ignore - ( IgnoreMap, + ( IgnoreSet, readIgnoreFile, writeIgnoreFile, isIgnored, - updateIgnoreMap, + collectIgnoreSet, getRouteIdentifier, ) where @@ -12,59 +12,42 @@ import Data.Aeson (decodeStrict) import Data.Aeson.Encode.Pretty (encodePretty) import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as LBS -import Data.Map.Strict qualified as Map import Data.Set qualified as Set -import Data.Text qualified as Text import Imports import LintOpenAPI.Report (renderRouteKey) import LintOpenAPI.Types --- | Maps a baseline version string (e.g. "v7") to a set of route identifiers. -type IgnoreMap = Map.Map Text (Set.Set Text) +-- | A flat set of route identifiers (either an @operationId@ or a rendered +-- route like @"GET /users/{_}"@) whose violations are suppressed. +type IgnoreSet = Set Text --- | Read an ignore file. Returns an empty map if it doesn't exist or is invalid. -readIgnoreFile :: FilePath -> IO IgnoreMap +-- | Read a flat ignore file: a JSON array of strings. Returns @Left@ if the +-- file is missing or malformed; there is no implicit empty ignore set. +readIgnoreFile :: FilePath -> IO (Either String IgnoreSet) readIgnoreFile path = do exists <- doesFileExist path if not exists - then pure Map.empty + then pure (Left ("ignore file not found: " <> path)) else do bytes <- BS.readFile path - case decodeStrict bytes of - Just m -> pure m - Nothing -> pure Map.empty + pure $ case decodeStrict bytes :: Maybe [Text] of + Just rs -> Right (Set.fromList rs) + Nothing -> Left ("ignore file is not a JSON array of strings: " <> path) --- | Write an ignore file using pretty JSON formatting. -writeIgnoreFile :: FilePath -> IgnoreMap -> IO () -writeIgnoreFile path imap = do - let bytes = encodePretty imap - LBS.writeFile path bytes +-- | Write an ignore file as a sorted JSON array of strings. +writeIgnoreFile :: FilePath -> IgnoreSet -> IO () +writeIgnoreFile path routes = + LBS.writeFile path (encodePretty (Set.toAscList routes)) -- | Extract the identifier for a route (either its operationId or rendered path). getRouteIdentifier :: ViolationContext -> Text getRouteIdentifier ctx = fromMaybe (renderRouteKey ctx.routeKey) ctx.routeId --- | Format the version key for the ignore map. -versionKey :: Maybe Int -> Text -versionKey (Just v) = "v" <> Text.pack (show v) -versionKey Nothing = "baseline" +-- | Check if a violation is ignored by the given ignore set. +isIgnored :: IgnoreSet -> ViolationContext -> Bool +isIgnored ignored ctx = Set.member (getRouteIdentifier ctx) ignored --- | Check if a violation is ignored by the given ignore map. -isIgnored :: IgnoreMap -> ViolationContext -> Bool -isIgnored imap ctx = - let vkey = versionKey ctx.baselineVersion - rkey = getRouteIdentifier ctx - in case Map.lookup vkey imap of - Just ignoredRoutes -> Set.member rkey ignoredRoutes - Nothing -> False - --- | Update the ignore map with new unignored violations. -updateIgnoreMap :: IgnoreMap -> [ViolationContext] -> IgnoreMap -updateIgnoreMap imap newViolations = - foldl' addViolation imap newViolations - where - addViolation acc ctx = - let vkey = versionKey ctx.baselineVersion - rkey = getRouteIdentifier ctx - in Map.insertWith Set.union vkey (Set.singleton rkey) acc +-- | Build the ignore set covering all given violations. +collectIgnoreSet :: [ViolationContext] -> IgnoreSet +collectIgnoreSet = Set.fromList . map getRouteIdentifier diff --git a/tools/lint-openapi-regression/test/LintOpenAPI/IgnoreSpec.hs b/tools/lint-openapi-regression/test/LintOpenAPI/IgnoreSpec.hs index 36f89485079..8aa896c11ea 100644 --- a/tools/lint-openapi-regression/test/LintOpenAPI/IgnoreSpec.hs +++ b/tools/lint-openapi-regression/test/LintOpenAPI/IgnoreSpec.hs @@ -1,6 +1,5 @@ module LintOpenAPI.IgnoreSpec (spec) where -import Data.Map.Strict qualified as Map import Data.Set qualified as Set import Data.Text qualified as Text import Imports @@ -12,46 +11,73 @@ spec :: Spec spec = describe "LintOpenAPI.Ignore" $ do describe "isIgnored" $ do it "ignores a violation by operationId" $ do - let imap = Map.singleton "v5" (Set.singleton "my-route") - ctx = mkCtx (Just 5) (Just "my-route") GET "/path" - isIgnored imap ctx `shouldBe` True + let iset = Set.singleton "my-route" + ctx = mkCtx (Just "my-route") GET "/path" + isIgnored iset ctx `shouldBe` True it "ignores a violation by rendered route" $ do - let imap = Map.singleton "v5" (Set.singleton "GET /path/{_}") - ctx = mkCtx (Just 5) Nothing GET "/path/{_}" - isIgnored imap ctx `shouldBe` True - - it "does not ignore if version mismatch" $ do - let imap = Map.singleton "v6" (Set.singleton "my-route") - ctx = mkCtx (Just 5) (Just "my-route") GET "/path" - isIgnored imap ctx `shouldBe` False + let iset = Set.singleton "GET /path/{_}" + ctx = mkCtx Nothing GET "/path/{_}" + isIgnored iset ctx `shouldBe` True it "does not ignore if route mismatch" $ do - let imap = Map.singleton "v5" (Set.singleton "other-route") - ctx = mkCtx (Just 5) (Just "my-route") GET "/path" - isIgnored imap ctx `shouldBe` False + let iset = Set.singleton "other-route" + ctx = mkCtx (Just "my-route") GET "/path" + isIgnored iset ctx `shouldBe` False + + it "ignores regardless of baseline version" $ do + let iset = Set.singleton "my-route" + ctx = (mkCtx (Just "my-route") GET "/path") {baselineVersion = Just 7} + isIgnored iset ctx `shouldBe` True + + describe "collectIgnoreSet" $ do + it "collects identifiers from all contexts" $ do + let ctxs = [mkCtx (Just "op-a") GET "/a", mkCtx Nothing POST "/b"] + collectIgnoreSet ctxs `shouldBe` Set.fromList ["op-a", "POST /b"] - describe "updateIgnoreMap" $ do - it "adds new violations to the ignore map" $ do - let imap = Map.singleton "v5" (Set.singleton "old-route") - ctx1 = mkCtx (Just 5) (Just "new-route") GET "/path1" - ctx2 = mkCtx (Just 6) Nothing POST "/path2" - newMap = updateIgnoreMap imap [ctx1, ctx2] + describe "readIgnoreFile/writeIgnoreFile" $ do + it "roundtrips a set of route identifiers" + $ withTempDir + $ \dir -> do + let path = dir <> "/v15.json" + routes = Set.fromList ["getUser", "POST /teams", "GET /users/{_}"] + writeIgnoreFile path routes + result <- readIgnoreFile path + result `shouldBe` Right routes - Map.lookup "v5" newMap `shouldBe` Just (Set.fromList ["old-route", "new-route"]) - Map.lookup "v6" newMap `shouldBe` Just (Set.singleton "POST /path2") + it "fails on a missing file" + $ withTempDir + $ \dir -> do + result <- readIgnoreFile (dir <> "/missing.json") + result `shouldSatisfy` \case + Left _ -> True + Right _ -> False + + it "fails on a non-array JSON document" + $ withTempDir + $ \dir -> do + let path = dir <> "/bad.json" + writeFile path "{\"v5\": [\"x\"]}" + result <- readIgnoreFile path + result `shouldSatisfy` \case + Left _ -> True + Right _ -> False -- | Helper to create a ViolationContext for testing -mkCtx :: Maybe Int -> Maybe Text -> HttpMethod -> Text -> ViolationContext -mkCtx ver opId method routeStr = +mkCtx :: Maybe Text -> HttpMethod -> Text -> ViolationContext +mkCtx opId method routeStr = let rk = RouteKey { method = method, route = NormalizedRoute [Literal (Text.drop 1 routeStr)] -- simplistic mock } in ViolationContext - { baselineVersion = ver, + { baselineVersion = Nothing, routeKey = rk, routeId = opId, violation = RouteRemoved } + +-- | Small helper: run in the system temporary directory. +withTempDir :: (FilePath -> IO a) -> IO a +withTempDir k = getTemporaryDirectory >>= k From b988b7cefd363965ba635f0508f24b1117068939 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Mon, 12 Oct 2026 19:43:52 +0200 Subject: [PATCH 04/10] Hello CI From f5015c8351bb9142918fb6c552d018dc3e56b168 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Tue, 13 Oct 2026 18:25:43 +0000 Subject: [PATCH 05/10] WPB-26627: enforce frozen OpenAPI baselines against live rendering in CI Extract versionedSwagger from brig's liveSwagger into Wire.API.Routes.Public.Swagger so the wire-api test suite can render the spec of every frozen version (v5-v18) and compare it to services/brig/docs/swagger-v*.json via lint-openapi-regression. The nix build splices the baselines into the sandbox src; per-version ignore files in libs/wire-api/test/unit/ignore/ record already-accepted drift, and any violation on a non-ignored route fails the compile job. --- .../WPB-26627-swagger-regression-test | 1 + libs/wire-api/default.nix | 3 + .../src/Wire/API/Routes/Public/Swagger.hs | 26 +++ .../Test/Wire/API/Routes/SwaggerRegression.hs | 149 ++++++++++++++++++ libs/wire-api/test/unit/Test/Wire/API/Run.hs | 2 + .../test/unit/ignore/swagger-ignore-v10.json | 20 +++ .../test/unit/ignore/swagger-ignore-v11.json | 20 +++ .../test/unit/ignore/swagger-ignore-v12.json | 25 +++ .../test/unit/ignore/swagger-ignore-v13.json | 19 +++ .../test/unit/ignore/swagger-ignore-v14.json | 20 +++ .../test/unit/ignore/swagger-ignore-v15.json | 17 ++ .../test/unit/ignore/swagger-ignore-v16.json | 20 +++ .../test/unit/ignore/swagger-ignore-v17.json | 1 + .../test/unit/ignore/swagger-ignore-v18.json | 1 + .../test/unit/ignore/swagger-ignore-v5.json | 42 +++++ .../test/unit/ignore/swagger-ignore-v6.json | 31 ++++ .../test/unit/ignore/swagger-ignore-v7.json | 27 ++++ .../test/unit/ignore/swagger-ignore-v8.json | 21 +++ .../test/unit/ignore/swagger-ignore-v9.json | 20 +++ libs/wire-api/wire-api.cabal | 11 +- nix/wire-server.nix | 4 + services/brig/src/Brig/API/Public.hs | 7 +- 22 files changed, 478 insertions(+), 9 deletions(-) create mode 100644 changelog.d/5-internal/WPB-26627-swagger-regression-test create mode 100644 libs/wire-api/test/unit/Test/Wire/API/Routes/SwaggerRegression.hs create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v10.json create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v11.json create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v12.json create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v13.json create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v14.json create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v15.json create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v16.json create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v17.json create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v18.json create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v5.json create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v6.json create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v7.json create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v8.json create mode 100644 libs/wire-api/test/unit/ignore/swagger-ignore-v9.json diff --git a/changelog.d/5-internal/WPB-26627-swagger-regression-test b/changelog.d/5-internal/WPB-26627-swagger-regression-test new file mode 100644 index 00000000000..cd8df9c3734 --- /dev/null +++ b/changelog.d/5-internal/WPB-26627-swagger-regression-test @@ -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 diff --git a/libs/wire-api/default.nix b/libs/wire-api/default.nix index cb9e759f8f9..078ec230357 100644 --- a/libs/wire-api/default.nix +++ b/libs/wire-api/default.nix @@ -66,6 +66,7 @@ , kan-extensions , lens , lib +, lint-openapi-regression , metrics-wai , mime , mtl @@ -271,6 +272,7 @@ mkDerivation { iso3166-country-codes iso639 lens + lint-openapi-regression metrics-wai openapi3 process @@ -282,6 +284,7 @@ mkDerivation { saml2-web-sso schema-profunctor servant + servant-openapi3 servant-server string-conversions tasty diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs b/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs index 0e4eaaa84f8..92a3183fcc3 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs @@ -22,11 +22,13 @@ module Wire.API.Routes.Public.Swagger ( 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 Servant.OpenApi (HasOpenApi) @@ -86,3 +88,27 @@ devVersionSwagger = & 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 diff --git a/libs/wire-api/test/unit/Test/Wire/API/Routes/SwaggerRegression.hs b/libs/wire-api/test/unit/Test/Wire/API/Routes/SwaggerRegression.hs new file mode 100644 index 00000000000..39e740be85d --- /dev/null +++ b/libs/wire-api/test/unit/Test/Wire/API/Routes/SwaggerRegression.hs @@ -0,0 +1,149 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2025 Wire Swiss GmbH +-- +-- 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 . + +-- | 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") diff --git a/libs/wire-api/test/unit/Test/Wire/API/Run.hs b/libs/wire-api/test/unit/Test/Wire/API/Run.hs index 3e30589b08c..65bd8e181fa 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Run.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Run.hs @@ -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 @@ -65,6 +66,7 @@ main = Roundtrip.CSV.tests, Routes.tests, Routes.OAuthScopes.tests, + Routes.SwaggerRegression.tests, Conversation.tests, Meeting.tests, MLS.tests, diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v10.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v10.json new file mode 100644 index 00000000000..94026312983 --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v10.json @@ -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" +] \ No newline at end of file diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v11.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v11.json new file mode 100644 index 00000000000..94026312983 --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v11.json @@ -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" +] \ No newline at end of file diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v12.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v12.json new file mode 100644 index 00000000000..54aa81214bb --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v12.json @@ -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" +] \ No newline at end of file diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v13.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v13.json new file mode 100644 index 00000000000..29bfb244612 --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v13.json @@ -0,0 +1,19 @@ +[ + "add-bot", + "add-members-to-conversation", + "create-app", + "create-conversation-code-unqualified", + "create-group-conversation", + "join-conversation-by-code-unqualified", + "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" +] \ No newline at end of file diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v14.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v14.json new file mode 100644 index 00000000000..3af1516fca5 --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v14.json @@ -0,0 +1,20 @@ +[ + "add-bot", + "add-members-to-conversation", + "create-app", + "create-conversation-code-unqualified", + "create-group-conversation", + "get-app", + "join-conversation-by-code-unqualified", + "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" +] \ No newline at end of file diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v15.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v15.json new file mode 100644 index 00000000000..c95e8e0b327 --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v15.json @@ -0,0 +1,17 @@ +[ + "add-bot", + "add-members-to-conversation", + "create-conversation-code-unqualified", + "join-conversation-by-code-unqualified", + "register", + "remove-bot", + "remove-code-unqualified", + "remove-member", + "update-channel-add-permission", + "update-conversation-access", + "update-conversation-history", + "update-conversation-message-timer", + "update-conversation-name", + "update-conversation-protocol", + "update-conversation-receipt-mode" +] \ No newline at end of file diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v16.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v16.json new file mode 100644 index 00000000000..10359998ec8 --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v16.json @@ -0,0 +1,20 @@ +[ + "add-bot", + "add-meeting-invitation", + "add-members-to-conversation", + "create-conversation-code-unqualified", + "delete-meeting", + "join-conversation-by-code-unqualified", + "register", + "remove-bot", + "remove-code-unqualified", + "remove-meeting-invitation", + "remove-member", + "update-channel-add-permission", + "update-conversation-access", + "update-conversation-history", + "update-conversation-message-timer", + "update-conversation-name", + "update-conversation-protocol", + "update-conversation-receipt-mode" +] \ No newline at end of file diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v17.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v17.json new file mode 100644 index 00000000000..0637a088a01 --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v17.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v18.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v18.json new file mode 100644 index 00000000000..0637a088a01 --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v18.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v5.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v5.json new file mode 100644 index 00000000000..33dabd4e552 --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v5.json @@ -0,0 +1,42 @@ +[ + "DELETE /conversations/{_}/bots/{_}", + "DELETE /conversations/{_}/code", + "DELETE /conversations/{_}/{_}/members/{_}/{_}", + "GET /", + "GET /conversations/one2one/{_}/{_}", + "GET /providers/{_}/services/{_}", + "GET /sso/initiate-login/{_}", + "POST /activate/send", + "POST /assets", + "POST /bot/assets", + "POST /bot/messages", + "POST /bot/users/prekeys", + "POST /broadcast/otr/messages", + "POST /broadcast/proteus/messages", + "POST /connections/{_}/{_}", + "POST /conversations", + "POST /conversations/join", + "POST /conversations/{_}/bots", + "POST /conversations/{_}/code", + "POST /conversations/{_}/otr/messages", + "POST /conversations/{_}/{_}/members", + "POST /conversations/{_}/{_}/proteus/messages", + "POST /mls/commit-bundles", + "POST /mls/messages", + "POST /password-reset/{_}", + "POST /provider/assets", + "POST /push/tokens", + "POST /register", + "POST /teams/{_}/invitations", + "POST /teams/{_}/legalhold/{_}", + "PUT /connections/{_}/{_}", + "PUT /conversations/{_}", + "PUT /conversations/{_}/message-timer", + "PUT /conversations/{_}/name", + "PUT /conversations/{_}/receipt-mode", + "PUT /conversations/{_}/{_}/access", + "PUT /conversations/{_}/{_}/message-timer", + "PUT /conversations/{_}/{_}/name", + "PUT /conversations/{_}/{_}/protocol", + "PUT /conversations/{_}/{_}/receipt-mode" +] \ No newline at end of file diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v6.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v6.json new file mode 100644 index 00000000000..5db1a2d1635 --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v6.json @@ -0,0 +1,31 @@ +[ + "DELETE /conversations/{_}/bots/{_}", + "DELETE /conversations/{_}/code", + "DELETE /conversations/{_}/{_}/members/{_}/{_}", + "GET /", + "GET /providers/{_}/services/{_}", + "GET /sso/initiate-login/{_}", + "POST /assets", + "POST /bot/assets", + "POST /conversations", + "POST /conversations/join", + "POST /conversations/{_}/bots", + "POST /conversations/{_}/code", + "POST /conversations/{_}/{_}/members", + "POST /mls/commit-bundles", + "POST /mls/messages", + "POST /password-reset/{_}", + "POST /provider/assets", + "POST /register", + "POST /teams/{_}/invitations", + "POST /teams/{_}/legalhold/{_}", + "PUT /conversations/{_}", + "PUT /conversations/{_}/message-timer", + "PUT /conversations/{_}/name", + "PUT /conversations/{_}/receipt-mode", + "PUT /conversations/{_}/{_}/access", + "PUT /conversations/{_}/{_}/message-timer", + "PUT /conversations/{_}/{_}/name", + "PUT /conversations/{_}/{_}/protocol", + "PUT /conversations/{_}/{_}/receipt-mode" +] \ No newline at end of file diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v7.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v7.json new file mode 100644 index 00000000000..aefeb93d224 --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v7.json @@ -0,0 +1,27 @@ +[ + "add-bot", + "add-members-to-conversation", + "assets-upload", + "assets-upload-v3_bot", + "assets-upload-v3_provider", + "auth-req", + "create-conversation-code-unqualified", + "create-group-conversation", + "join-conversation-by-code-unqualified", + "mls-commit-bundle", + "mls-message", + "post-password-reset-key-deprecated", + "register", + "remove-bot", + "remove-code-unqualified", + "remove-member", + "update-conversation-access", + "update-conversation-message-timer", + "update-conversation-message-timer-unqualified", + "update-conversation-name", + "update-conversation-name-deprecated", + "update-conversation-name-unqualified", + "update-conversation-protocol", + "update-conversation-receipt-mode", + "update-conversation-receipt-mode-unqualified" +] \ No newline at end of file diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v8.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v8.json new file mode 100644 index 00000000000..a3c51ac41c4 --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v8.json @@ -0,0 +1,21 @@ +[ + "add-bot", + "add-members-to-conversation", + "auth-req", + "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" +] \ No newline at end of file diff --git a/libs/wire-api/test/unit/ignore/swagger-ignore-v9.json b/libs/wire-api/test/unit/ignore/swagger-ignore-v9.json new file mode 100644 index 00000000000..9cb9c3da007 --- /dev/null +++ b/libs/wire-api/test/unit/ignore/swagger-ignore-v9.json @@ -0,0 +1,20 @@ +[ + "add-bot", + "add-members-to-conversation", + "create-conversation-code-unqualified", + "create-group-conversation@v9", + "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" +] \ No newline at end of file diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index e7f4e0886e0..48876f72e7a 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -729,6 +729,7 @@ test-suite wire-api-tests Test.Wire.API.Roundtrip.PostgresMarshall Test.Wire.API.Routes Test.Wire.API.Routes.OAuthScopes + Test.Wire.API.Routes.SwaggerRegression Test.Wire.API.Routes.Version Test.Wire.API.Routes.Version.Wai Test.Wire.API.Run @@ -742,16 +743,16 @@ test-suite wire-api-tests hs-source-dirs: test/unit build-depends: - , aeson >=2.0.1.0 + , aeson >=2.0.1.0 , aeson-qq , async , base , binary , bytestring - , bytestring-arbitrary >=0.1.3 + , bytestring-arbitrary >=0.1.3 , bytestring-conversion , cassava - , containers >=0.5 + , containers >=0.5 , crypton , data-default , file-embed @@ -762,6 +763,7 @@ test-suite wire-api-tests , http-types , imports , lens + , lint-openapi-regression , metrics-wai , openapi3 , process @@ -771,6 +773,7 @@ test-suite wire-api-tests , regex-tdfa , schema-profunctor , servant + , servant-openapi3 , servant-server , string-conversions , tasty @@ -780,7 +783,7 @@ test-suite wire-api-tests , template-haskell , text , time - , types-common >=0.16 + , types-common >=0.16 , unliftio , uuid , vector diff --git a/nix/wire-server.nix b/nix/wire-server.nix index c3a3010efe6..b67b3ff8e4e 100644 --- a/nix/wire-server.nix +++ b/nix/wire-server.nix @@ -171,6 +171,10 @@ let chmod -R u+w $out mkdir -p $out/test/unit/generated cp ${../charts/nginz/values.yaml} $out/test/unit/generated/nginz-values.yaml + # Frozen OpenAPI baselines for Test.Wire.API.Routes.SwaggerRegression; + # part of 'src' so baseline changes invalidate the build (and get reviewed). + mkdir -p $out/test/unit/generated/baselines + cp ${../services/brig/docs}/swagger-v*.json $out/test/unit/generated/baselines/ ''; }); }; diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index a95af09d2b7..9c4852a8ad8 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -55,7 +55,7 @@ import Brig.User.Client qualified as API import Cassandra qualified as C import Cassandra qualified as Data import Control.Error hiding (bool, note) -import Control.Lens ((.~), (?~)) +import Control.Lens ((?~)) import Control.Monad.Except import Data.Aeson import Data.ByteString (fromStrict) @@ -259,10 +259,7 @@ liveSwagger :: Servant.Server SwaggerDocsAPIBase liveSwagger _ vn = swaggerSchemaUIServer $ - allServicesSwagger @v - & S.info . S.title .~ "Wire-Server API" - & S.servers .~ [S.Server ("/" <> toUrlPiece vn) Nothing mempty] - & cleanupSwagger + versionedSwagger (Proxy @v) vn -- | Serves Swagger docs for public endpoints -- From 3f0ae907f2a117c3a7bd692349eac82e83b6b7b9 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 14 Oct 2026 20:33:56 +0200 Subject: [PATCH 06/10] Hello CI From cb29d25aa76e01ca79e76b65989061bc7ba503e6 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 14 Oct 2026 22:23:47 +0200 Subject: [PATCH 07/10] Hello CI From 91494dd4173fbec6d5d53106858cfc2490fed53d Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Thu, 15 Oct 2026 00:23:46 +0200 Subject: [PATCH 08/10] Hello CI From 819b52f2025747dcd73fc43de11a5c5630d76465 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Thu, 15 Oct 2026 00:29:16 +0200 Subject: [PATCH 09/10] Hello CI From c00a7e049aa5fa405d66fd0df2cca7faa2caf5c2 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Thu, 15 Oct 2026 00:34:26 +0200 Subject: [PATCH 10/10] Hello CI