diff --git a/.lint-ignore.json b/.lint-ignore.json new file mode 100644 index 0000000000..33742201b5 --- /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 1cf1aa8ecb..14b5bd9e75 100644 --- a/cabal.project +++ b/cabal.project @@ -60,6 +60,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 5562225f67..d65af06bc5 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 0000000000..dba13ed2dd --- /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 0000000000..2767c7972e --- /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 0000000000..6da480ab6a --- /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 0000000000..3885e38875 --- /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 0000000000..065c5b77a1 --- /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 0000000000..6b4ac7dfc2 --- /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 0000000000..5603592b96 --- /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 0000000000..7b7a68a1ad --- /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 0000000000..9ec34ded69 --- /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 0000000000..1626bccafa --- /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 0000000000..82aed1a5c6 --- /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 0000000000..3f52981de6 --- /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 0000000000..36f8948507 --- /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 0000000000..3543da873b --- /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 0000000000..e4cb32936d --- /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 0000000000..a824f8c30c --- /dev/null +++ b/tools/lint-openapi-regression/test/Spec.hs @@ -0,0 +1 @@ +{-# OPTIONS_GHC -F -pgmF hspec-discover #-}