├── .gitignore ├── .gitreview ├── .rspec ├── .rubocop.yml ├── .yardopts ├── CREDITS ├── Gemfile ├── LICENSE.md ├── README.md ├── Rakefile ├── lib ├── mediawiki_api.rb └── mediawiki_api │ ├── client.rb │ ├── exceptions.rb │ ├── response.rb │ └── version.rb ├── mediawiki_api.gemspec └── spec ├── client_spec.rb ├── exceptions_spec.rb ├── response_spec.rb ├── spec_helper.rb └── support └── request_helpers.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | /.yardoc/ 3 | /doc/ 4 | .ruby-version 5 | .ruby-gemset 6 | .gem 7 | Gemfile.lock 8 | *.gem 9 | .idea/ 10 | .vscode/ -------------------------------------------------------------------------------- /.gitreview: -------------------------------------------------------------------------------- 1 | [gerrit] 2 | host=gerrit.wikimedia.org 3 | port=29418 4 | project=mediawiki/ruby/api.git 5 | defaultbranch=master 6 | defaultrebase=0 7 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # require: rubocop-rspec # make specs compliant one day 2 | # until then: 3 | 4 | AllCops: 5 | SuggestExtensions: false 6 | TargetRubyVersion: 2.6 7 | StyleGuideCopsOnly: true 8 | NewCops: enable 9 | 10 | Layout/LineLength: 11 | Max: 100 12 | 13 | Metrics/MethodLength: 14 | Enabled: false 15 | 16 | Style/Alias: 17 | Enabled: false 18 | 19 | Layout/DotPosition: 20 | EnforcedStyle: trailing 21 | 22 | Style/SignalException: 23 | Enabled: false 24 | 25 | Style/StringLiterals: 26 | EnforcedStyle: single_quotes 27 | 28 | Style/TrivialAccessors: 29 | ExactNameMatch: true 30 | 31 | # See https://github.com/bbatsov/rubocop/issues/4637 32 | Layout/SpaceAroundOperators: 33 | Enabled: false 34 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --title "MediaWiki Ruby API" 2 | - '*.md' 3 | -------------------------------------------------------------------------------- /CREDITS: -------------------------------------------------------------------------------- 1 | mediawiki_ruby_api_client is a collaborative project released under the 2 | GNU General Public License v2. We would like to recognize the 3 | following names for their contribution to the product. 4 | 5 | For further details on licensing, see the LICENSE.md file. 6 | 7 | == Developers == 8 | * Amir Aharoni 9 | * [Asaf Bartov](https://github.com/abartov) (current maintainer) 10 | * Chris McMahon 11 | * Dan Duvall 12 | * Jeff Hall 13 | * Juliusz Gonera 14 | * Željko Filipin 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in mediawiki_api.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License and copyright information 2 | 3 | ## License 4 | 5 | MediaWiki is licensed under the terms of the GNU General Public License, 6 | version 2 or later. Derivative works and later versions of the code must be 7 | free software licensed under the same or a compatible license. This includes 8 | "extensions" that use MediaWiki functions or variables; see 9 | http://www.gnu.org/licenses/gpl-faq.html#GPLAndPlugins for details. 10 | 11 | For the full text of version 2 of the license, see 12 | https://www.gnu.org/licenses/gpl-2.0.html or '''GNU General Public License''' 13 | below. 14 | 15 | ## Copyright owners 16 | 17 | MediaWiki contributors, including those listed in the CREDITS file, hold the 18 | copyright to this work. 19 | 20 | ## Additional license information 21 | 22 | Some components of MediaWiki imported from other projects may be under other 23 | Free and Open Source, or Free Culture, licenses. Specific details of their 24 | licensing information can be found in those components. 25 | 26 | Sections of code written exclusively by Lee Crocker or Erik Moeller are also 27 | released into the public domain, which does not impair the obligations of users 28 | under the GPL for use of the whole code or other sections thereof. 29 | 30 | MediaWiki uses the following Creative Commons icons to illustrate links to the 31 | CC licenses: 32 | 33 | * skins/common/images/cc-by-nc-sa.png 34 | * skins/common/images/cc-by-sa.png 35 | 36 | These icons are trademarked, and used subject to the CC trademark license, 37 | available at http://creativecommons.org/policies#trademark 38 | 39 | # GNU GENERAL PUBLIC LICENSE 40 | 41 | Version 2, June 1991 42 | 43 | Copyright (C) 1989, 1991 Free Software Foundation, Inc. 44 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA 45 | Everyone is permitted to copy and distribute verbatim copies 46 | of this license document, but changing it is not allowed. 47 | 48 | ## Preamble 49 | 50 | The licenses for most software are designed to take away your 51 | freedom to share and change it. By contrast, the GNU General Public 52 | License is intended to guarantee your freedom to share and change free 53 | software--to make sure the software is free for all its users. This 54 | General Public License applies to most of the Free Software 55 | Foundation's software and to any other program whose authors commit to 56 | using it. (Some other Free Software Foundation software is covered by 57 | the GNU Library General Public License instead.) You can apply it to 58 | your programs, too. 59 | 60 | When we speak of free software, we are referring to freedom, not 61 | price. Our General Public Licenses are designed to make sure that you 62 | have the freedom to distribute copies of free software (and charge for 63 | this service if you wish), that you receive source code or can get it 64 | if you want it, that you can change the software or use pieces of it 65 | in new free programs; and that you know you can do these things. 66 | 67 | To protect your rights, we need to make restrictions that forbid 68 | anyone to deny you these rights or to ask you to surrender the rights. 69 | These restrictions translate to certain responsibilities for you if you 70 | distribute copies of the software, or if you modify it. 71 | 72 | For example, if you distribute copies of such a program, whether 73 | gratis or for a fee, you must give the recipients all the rights that 74 | you have. You must make sure that they, too, receive or can get the 75 | source code. And you must show them these terms so they know their 76 | rights. 77 | 78 | We protect your rights with two steps: (1) copyright the software, and 79 | (2) offer you this license which gives you legal permission to copy, 80 | distribute and/or modify the software. 81 | 82 | Also, for each author's protection and ours, we want to make certain 83 | that everyone understands that there is no warranty for this free 84 | software. If the software is modified by someone else and passed on, we 85 | want its recipients to know that what they have is not the original, so 86 | that any problems introduced by others will not reflect on the original 87 | authors' reputations. 88 | 89 | Finally, any free program is threatened constantly by software 90 | patents. We wish to avoid the danger that redistributors of a free 91 | program will individually obtain patent licenses, in effect making the 92 | program proprietary. To prevent this, we have made it clear that any 93 | patent must be licensed for everyone's free use or not licensed at all. 94 | 95 | The precise terms and conditions for copying, distribution and 96 | modification follow. 97 | 98 | # TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 99 | 100 | '''0.''' This License applies to any program or other work which contains 101 | a notice placed by the copyright holder saying it may be distributed 102 | under the terms of this General Public License. The "Program", below, 103 | refers to any such program or work, and a "work based on the Program" 104 | means either the Program or any derivative work under copyright law: 105 | that is to say, a work containing the Program or a portion of it, 106 | either verbatim or with modifications and/or translated into another 107 | language. (Hereinafter, translation is included without limitation in 108 | the term "modification".) Each licensee is addressed as "you". 109 | 110 | Activities other than copying, distribution and modification are not 111 | covered by this License; they are outside its scope. The act of 112 | running the Program is not restricted, and the output from the Program 113 | is covered only if its contents constitute a work based on the 114 | Program (independent of having been made by running the Program). 115 | Whether that is true depends on what the Program does. 116 | 117 | '''1.''' You may copy and distribute verbatim copies of the Program's 118 | source code as you receive it, in any medium, provided that you 119 | conspicuously and appropriately publish on each copy an appropriate 120 | copyright notice and disclaimer of warranty; keep intact all the 121 | notices that refer to this License and to the absence of any warranty; 122 | and give any other recipients of the Program a copy of this License 123 | along with the Program. 124 | 125 | You may charge a fee for the physical act of transferring a copy, and 126 | you may at your option offer warranty protection in exchange for a fee. 127 | 128 | '''2.''' You may modify your copy or copies of the Program or any portion 129 | of it, thus forming a work based on the Program, and copy and 130 | distribute such modifications or work under the terms of Section 1 131 | above, provided that you also meet all of these conditions: 132 | 133 | '''a)''' You must cause the modified files to carry prominent notices 134 | stating that you changed the files and the date of any change. 135 | 136 | '''b)''' You must cause any work that you distribute or publish, that in 137 | whole or in part contains or is derived from the Program or any 138 | part thereof, to be licensed as a whole at no charge to all third 139 | parties under the terms of this License. 140 | 141 | '''c)''' If the modified program normally reads commands interactively 142 | when run, you must cause it, when started running for such 143 | interactive use in the most ordinary way, to print or display an 144 | announcement including an appropriate copyright notice and a 145 | notice that there is no warranty (or else, saying that you provide 146 | a warranty) and that users may redistribute the program under 147 | these conditions, and telling the user how to view a copy of this 148 | License. (Exception: if the Program itself is interactive but 149 | does not normally print such an announcement, your work based on 150 | the Program is not required to print an announcement.) 151 | 152 | These requirements apply to the modified work as a whole. If 153 | identifiable sections of that work are not derived from the Program, 154 | and can be reasonably considered independent and separate works in 155 | themselves, then this License, and its terms, do not apply to those 156 | sections when you distribute them as separate works. But when you 157 | distribute the same sections as part of a whole which is a work based 158 | on the Program, the distribution of the whole must be on the terms of 159 | this License, whose permissions for other licensees extend to the 160 | entire whole, and thus to each and every part regardless of who wrote it. 161 | 162 | Thus, it is not the intent of this section to claim rights or contest 163 | your rights to work written entirely by you; rather, the intent is to 164 | exercise the right to control the distribution of derivative or 165 | collective works based on the Program. 166 | 167 | In addition, mere aggregation of another work not based on the Program 168 | with the Program (or with a work based on the Program) on a volume of 169 | a storage or distribution medium does not bring the other work under 170 | the scope of this License. 171 | 172 | '''3.''' You may copy and distribute the Program (or a work based on it, 173 | under Section 2) in object code or executable form under the terms of 174 | Sections 1 and 2 above provided that you also do one of the following: 175 | 176 | '''a)''' Accompany it with the complete corresponding machine-readable 177 | source code, which must be distributed under the terms of Sections 178 | 1 and 2 above on a medium customarily used for software interchange; or, 179 | 180 | '''b)''' Accompany it with a written offer, valid for at least three 181 | years, to give any third party, for a charge no more than your 182 | cost of physically performing source distribution, a complete 183 | machine-readable copy of the corresponding source code, to be 184 | distributed under the terms of Sections 1 and 2 above on a medium 185 | customarily used for software interchange; or, 186 | 187 | '''c)''' Accompany it with the information you received as to the offer 188 | to distribute corresponding source code. (This alternative is 189 | allowed only for noncommercial distribution and only if you 190 | received the program in object code or executable form with such 191 | an offer, in accord with Subsection b above.) 192 | 193 | The source code for a work means the preferred form of the work for 194 | making modifications to it. For an executable work, complete source 195 | code means all the source code for all modules it contains, plus any 196 | associated interface definition files, plus the scripts used to 197 | control compilation and installation of the executable. However, as a 198 | special exception, the source code distributed need not include 199 | anything that is normally distributed (in either source or binary 200 | form) with the major components (compiler, kernel, and so on) of the 201 | operating system on which the executable runs, unless that component 202 | itself accompanies the executable. 203 | 204 | If distribution of executable or object code is made by offering 205 | access to copy from a designated place, then offering equivalent 206 | access to copy the source code from the same place counts as 207 | distribution of the source code, even though third parties are not 208 | compelled to copy the source along with the object code. 209 | 210 | '''4.''' You may not copy, modify, sublicense, or distribute the Program 211 | except as expressly provided under this License. Any attempt 212 | otherwise to copy, modify, sublicense or distribute the Program is 213 | void, and will automatically terminate your rights under this License. 214 | However, parties who have received copies, or rights, from you under 215 | this License will not have their licenses terminated so long as such 216 | parties remain in full compliance. 217 | 218 | '''5.''' You are not required to accept this License, since you have not 219 | signed it. However, nothing else grants you permission to modify or 220 | distribute the Program or its derivative works. These actions are 221 | prohibited by law if you do not accept this License. Therefore, by 222 | modifying or distributing the Program (or any work based on the 223 | Program), you indicate your acceptance of this License to do so, and 224 | all its terms and conditions for copying, distributing or modifying 225 | the Program or works based on it. 226 | 227 | '''6.''' Each time you redistribute the Program (or any work based on the 228 | Program), the recipient automatically receives a license from the 229 | original licensor to copy, distribute or modify the Program subject to 230 | these terms and conditions. You may not impose any further 231 | restrictions on the recipients' exercise of the rights granted herein. 232 | You are not responsible for enforcing compliance by third parties to 233 | this License. 234 | 235 | '''7.''' If, as a consequence of a court judgment or allegation of patent 236 | infringement or for any other reason (not limited to patent issues), 237 | conditions are imposed on you (whether by court order, agreement or 238 | otherwise) that contradict the conditions of this License, they do not 239 | excuse you from the conditions of this License. If you cannot 240 | distribute so as to satisfy simultaneously your obligations under this 241 | License and any other pertinent obligations, then as a consequence you 242 | may not distribute the Program at all. For example, if a patent 243 | license would not permit royalty-free redistribution of the Program by 244 | all those who receive copies directly or indirectly through you, then 245 | the only way you could satisfy both it and this License would be to 246 | refrain entirely from distribution of the Program. 247 | 248 | If any portion of this section is held invalid or unenforceable under 249 | any particular circumstance, the balance of the section is intended to 250 | apply and the section as a whole is intended to apply in other 251 | circumstances. 252 | 253 | It is not the purpose of this section to induce you to infringe any 254 | patents or other property right claims or to contest validity of any 255 | such claims; this section has the sole purpose of protecting the 256 | integrity of the free software distribution system, which is 257 | implemented by public license practices. Many people have made 258 | generous contributions to the wide range of software distributed 259 | through that system in reliance on consistent application of that 260 | system; it is up to the author/donor to decide if he or she is willing 261 | to distribute software through any other system and a licensee cannot 262 | impose that choice. 263 | 264 | This section is intended to make thoroughly clear what is believed to 265 | be a consequence of the rest of this License. 266 | 267 | '''8.''' If the distribution and/or use of the Program is restricted in 268 | certain countries either by patents or by copyrighted interfaces, the 269 | original copyright holder who places the Program under this License 270 | may add an explicit geographical distribution limitation excluding 271 | those countries, so that distribution is permitted only in or among 272 | countries not thus excluded. In such case, this License incorporates 273 | the limitation as if written in the body of this License. 274 | 275 | '''9.''' The Free Software Foundation may publish revised and/or new versions 276 | of the General Public License from time to time. Such new versions will 277 | be similar in spirit to the present version, but may differ in detail to 278 | address new problems or concerns. 279 | 280 | Each version is given a distinguishing version number. If the Program 281 | specifies a version number of this License which applies to it and "any 282 | later version", you have the option of following the terms and conditions 283 | either of that version or of any later version published by the Free 284 | Software Foundation. If the Program does not specify a version number of 285 | this License, you may choose any version ever published by the Free Software 286 | Foundation. 287 | 288 | '''10.''' If you wish to incorporate parts of the Program into other free 289 | programs whose distribution conditions are different, write to the author 290 | to ask for permission. For software which is copyrighted by the Free 291 | Software Foundation, write to the Free Software Foundation; we sometimes 292 | make exceptions for this. Our decision will be guided by the two goals 293 | of preserving the free status of all derivatives of our free software and 294 | of promoting the sharing and reuse of software generally. 295 | 296 | ## NO WARRANTY 297 | 298 | '''11.''' BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 299 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 300 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 301 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 302 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 303 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 304 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 305 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 306 | REPAIR OR CORRECTION. 307 | 308 | '''12.''' IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 309 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 310 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 311 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 312 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 313 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 314 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 315 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 316 | POSSIBILITY OF SUCH DAMAGES. 317 | 318 | '''END OF TERMS AND CONDITIONS''' 319 | 320 | # How to Apply These Terms to Your New Programs 321 | 322 | If you develop a new program, and you want it to be of the greatest 323 | possible use to the public, the best way to achieve this is to make it 324 | free software which everyone can redistribute and change under these terms. 325 | 326 | To do so, attach the following notices to the program. It is safest 327 | to attach them to the start of each source file to most effectively 328 | convey the exclusion of warranty; and each file should have at least 329 | the "copyright" line and a pointer to where the full notice is found. 330 | 331 | 332 | 333 | Copyright (C) 334 | 335 | This program is free software; you can redistribute it and/or modify 336 | it under the terms of the GNU General Public License as published by 337 | the Free Software Foundation; either version 2 of the License, or 338 | (at your option) any later version. 339 | 340 | This program is distributed in the hope that it will be useful, 341 | but WITHOUT ANY WARRANTY; without even the implied warranty of 342 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 343 | GNU General Public License for more details. 344 | 345 | You should have received a copy of the GNU General Public License 346 | along with this program; if not, write to the Free Software 347 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA 348 | 349 | 350 | Also add information on how to contact you by electronic and paper mail. 351 | 352 | If the program is interactive, make it output a short notice like this 353 | when it starts in an interactive mode: 354 | 355 | Gnomovision version 69, Copyright (C) year name of author 356 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 357 | This is free software, and you are welcome to redistribute it 358 | under certain conditions; type `show c' for details. 359 | 360 | The hypothetical commands `show w' and `show c' should show the appropriate 361 | parts of the General Public License. Of course, the commands you use may 362 | be called something other than `show w' and `show c'; they could even be 363 | mouse-clicks or menu items--whatever suits your program. 364 | 365 | You should also get your employer (if you work as a programmer) or your 366 | school, if any, to sign a "copyright disclaimer" for the program, if 367 | necessary. Here is a sample; alter the names: 368 | 369 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 370 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 371 | 372 | , 1 April 1989 373 | 374 | Ty Coon, President of Vice 375 | 376 | This General Public License does not permit incorporating your program into 377 | proprietary programs. If your program is a subroutine library, you may 378 | consider it more useful to permit linking proprietary applications with the 379 | library. If this is what you want to do, use the GNU Library General 380 | Public License instead of this License. 381 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MediaWiki API 2 | 3 | A library for interacting with MediaWiki API from Ruby. Uses adapter-agnostic 4 | Faraday gem to talk to the API. 5 | 6 | ## Installation 7 | 8 | Add this line to your application's Gemfile: 9 | 10 | gem "mediawiki_api" 11 | 12 | And then execute: 13 | 14 | $ bundle 15 | 16 | Or install it yourself as: 17 | 18 | $ gem install mediawiki_api 19 | 20 | ## Usage 21 | 22 | Assuming you have MediaWiki installed via [MediaWiki-Vagrant](https://www.mediawiki.org/wiki/MediaWiki-Vagrant). 23 | 24 | ```ruby 25 | require "mediawiki_api" 26 | 27 | client = MediawikiApi::Client.new "http://127.0.0.1:8080/w/api.php" 28 | client.log_in "username", "password" # default Vagrant username and password are "Admin", "vagrant" 29 | client.oauth_access_token("user_oauth_token") # INSTEAD of logging in, will make all actions as the user authenticated via OAuth 30 | client.create_account "username", "password" # will not work on wikis that require CAPTCHA, like Wikipedia 31 | client.create_page "title", "content" 32 | client.get_wikitext "title" 33 | client.protect_page "title", "reason", "protections" # protections are optional, default is "edit=sysop|move=sysop" 34 | client.delete_page "title", "reason" 35 | client.upload_image "filename", "path", "comment", "ignorewarnings" 36 | client.watch_page "title" 37 | client.unwatch_page "title" 38 | client.meta :siteinfo, siprop: "extensions" 39 | client.prop :info, titles: "Some page" 40 | client.query titles: ["Some page", "Some other page"] 41 | ``` 42 | 43 | ## Advanced Usage 44 | 45 | Any API action can be requested using `#action`. See the 46 | [MediaWiki API documentation](http://www.mediawiki.org/wiki/API) for supported 47 | actions and parameters. 48 | 49 | By default, the client will attempt to get a csrf token before attempting the 50 | action. For actions that do not require a token, you can specify 51 | `token_type: false` to avoid requesting the unnecessary token before the real 52 | request. For example: 53 | 54 | ```ruby 55 | client.action :parse, page: 'Main Page', token_type: false 56 | ``` 57 | 58 | ## Links 59 | 60 | MediaWiki API gem at: [Gerrit](https://gerrit.wikimedia.org/r/#/admin/projects/mediawiki/ruby/api), [GitHub](https://github.com/wikimedia/mediawiki-ruby-api), [RubyGems](https://rubygems.org/gems/mediawiki_api), [Code Climate](https://codeclimate.com/github/wikimedia/mediawiki-ruby-api). 61 | 62 | 63 | ## Contributing 64 | 65 | See https://www.mediawiki.org/wiki/Gerrit 66 | 67 | ## Release notes 68 | 69 | ### 0.9.0 2024-05-06 70 | - Upgraded underlying Faraday gem to 2.x (> 2.7.0) 71 | - Updated required Ruby to 2.6.x 72 | 73 | ### 0.8.0 2023-10-26 74 | - Add `oauth_access_token`, allowing authenticated actions on behalf of users via [Wikimedia's OAuth service](https://www.mediawiki.org/wiki/OAuth/For_Developers). Obtaining the access token is up to this gem's user. In Ruby, one can use the `[oauth2](https://gitlab.com/oauth-xx/oauth2/)` gem. A working example can be seen in the source code of [the Luthor tool](https://gitlab.wikimedia.org/toolforge-repos/luthor/-/blob/master/app/controllers/usage_controller.rb). 75 | - New maintainer: @abartov 76 | 77 | ### 0.7.1 2017-01-31 78 | - Add `text` param to `MediawikiApi::Client#upload_image` 79 | 80 | ### 0.7.0 2016-08-03 81 | - Automatically follow redirects for all API requests. 82 | 83 | ### 0.6.0 2016-05-25 84 | - Update account creation code for AuthManager. This change updates the gem to test which API 85 | flavor is in use, then send requests accordingly. 86 | 87 | ### 0.5.0 2015-09-04 88 | - Client cookies can now be read and modified via MediawikiApi::Client#cookies. 89 | - Logging in will recurse upon a `NeedToken` API error only once to avoid 90 | infinite recursion in cases where authentication is repeatedly unsuccessful. 91 | 92 | ### 0.4.1 2015-06-17 93 | - Allow for response-less ApiError exceptions to make mocking in tests easier 94 | 95 | ### 0.4.0 2015-06-16 96 | - Use action=query&meta=tokens to fetch tokens, instead of deprecated action=tokens 97 | 98 | ### 0.3.1 2015-01-06 99 | - Actions now automatically refresh token and re-submit action if first attempt returns 'badtoken'. 100 | 101 | ### 0.3.0 2014-10-14 102 | 103 | - HTTP 400 and 500 responses now result in an HttpError exception. 104 | - Edit failures now result in an EditError exception. 105 | 106 | ### 0.2.1 2014-08-26 107 | 108 | - Fix error handling for token requests 109 | 110 | ### 0.2.0 2014-08-06 111 | 112 | - Automatic response parsing. 113 | - Handling of API error responses. 114 | - Watch/unwatch support. 115 | - Query support. 116 | - Public MediawikiApi::Client#action method for advanced API use. 117 | 118 | ### 0.1.4 2014-07-18 119 | 120 | - Added MediawikiApi::Client#protect_page. 121 | - Updated documentation. 122 | 123 | ### 0.1.3 2014-06-28 124 | 125 | - Added MediawikiApi::Client#upload_image. 126 | 127 | ### 0.1.2 2014-04-11 128 | 129 | - Added MediawikiApi::Client#get_wikitext. 130 | 131 | ### 0.1.1 2014-04-01 132 | 133 | - Updated documentation. 134 | 135 | ### 0.1.0 2014-03-13 136 | 137 | - Complete refactoring. 138 | - Removed MediawikiApi#create_article, #create_user and #delete_article. 139 | - Added MediawikiApi::Client#new, #log_in, #create_page, #delete_page, #create_account. 140 | - Added unit tests. 141 | 142 | ### 0.0.2 2014-02-11 143 | 144 | - Added MediawikiApi#delete_article. 145 | 146 | ### 0.0.1 2014-02-07 147 | 148 | - Added MediawikiApi#create_article and #create_user. 149 | 150 | ## {file:LICENSE.md} 151 | 152 | © Copyright 2013-2023, Wikimedia Foundation & Contributors. Released under the terms of the GNU General Public License, version 2 or later. 153 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # pre-flight 2 | require 'bundler/setup' 3 | require 'bundler/gem_tasks' 4 | 5 | require 'rspec/core/rake_task' 6 | require 'rubocop/rake_task' 7 | require 'yard' 8 | 9 | RSpec::Core::RakeTask.new(:spec) 10 | RuboCop::RakeTask.new(:rubocop) 11 | YARD::Rake::YardocTask.new(:yard) 12 | 13 | task default: [:test] 14 | 15 | desc 'Run all build/tests commands (CI entry point)' 16 | task test: %i[build rubocop spec yard] 17 | 18 | desc 'Generate all documentations' 19 | task doc: [:yard] 20 | -------------------------------------------------------------------------------- /lib/mediawiki_api.rb: -------------------------------------------------------------------------------- 1 | require 'mediawiki_api/client' 2 | -------------------------------------------------------------------------------- /lib/mediawiki_api/client.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | require 'faraday/multipart' 3 | require 'faraday-cookie_jar' 4 | require 'faraday/follow_redirects' 5 | require 'json' 6 | 7 | require 'mediawiki_api/exceptions' 8 | require 'mediawiki_api/response' 9 | 10 | module MediawikiApi 11 | # high level client for MediaWiki 12 | class Client 13 | FORMAT = 'json' 14 | 15 | attr_reader :cookies 16 | attr_accessor :logged_in 17 | 18 | alias_method :logged_in?, :logged_in 19 | 20 | def initialize(url, log: false) 21 | @cookies = HTTP::CookieJar.new 22 | 23 | @conn = Faraday.new(url: url) do |faraday| 24 | faraday.request :multipart 25 | faraday.request :url_encoded 26 | faraday.response :logger if log 27 | faraday.use :cookie_jar, jar: @cookies 28 | faraday.response :follow_redirects 29 | faraday.adapter Faraday.default_adapter 30 | end 31 | 32 | @logged_in = false 33 | @tokens = {} 34 | end 35 | 36 | def action(name, params = {}) 37 | raw_action(name, params) 38 | rescue ApiError => e 39 | # propagate the exception 40 | raise unless e.code == 'badtoken' 41 | @tokens.clear # ensure fresh token on re-try 42 | raw_action(name, params) # no rescue this time; only re-try once. 43 | end 44 | 45 | # set the OAuth access token to be used for all subsequent actions 46 | # (obtaining the token is up to you) 47 | def oauth_access_token(access_token) 48 | @conn.headers['Authorization'] = "Bearer #{access_token}" 49 | end 50 | 51 | def create_account(username, password) 52 | params = { modules: 'createaccount', token_type: false } 53 | d = action(:paraminfo, params).data 54 | params = d['modules'] && d['modules'][0] && d['modules'][0]['parameters'] 55 | raise CreateAccountError, 'unexpected API response format' if !params || !params.map 56 | params = params.map{ |o| o['name'] } 57 | 58 | if params.include? 'requests' 59 | create_account_new(username, password) 60 | else 61 | create_account_old(username, password) 62 | end 63 | end 64 | 65 | def create_account_new(username, password) 66 | # post-AuthManager 67 | data = action(:query, { meta: 'tokens', type: 'createaccount', token_type: false }).data 68 | token = data['tokens'] && data['tokens']['createaccounttoken'] 69 | raise CreateAccountError, 'failed to get createaccount API token' unless token 70 | 71 | data = action(:createaccount, { 72 | username: username, 73 | password: password, 74 | retype: password, 75 | createreturnurl: 'http://example.com', # won't be used but must be a valid URL 76 | createtoken: token, 77 | token_type: false 78 | }).data 79 | raise CreateAccountError, data['message'] if data['status'] != 'PASS' 80 | data 81 | end 82 | 83 | def create_account_old(username, password, token = nil) 84 | # pre-AuthManager 85 | params = { name: username, password: password, token_type: false } 86 | params[:token] = token unless token.nil? 87 | 88 | data = action(:createaccount, params).data 89 | 90 | case data['result'] 91 | when 'Success' 92 | @logged_in = true 93 | @tokens.clear 94 | when 'NeedToken' 95 | data = create_account_old(username, password, data['token']) 96 | else 97 | raise CreateAccountError, data['result'] 98 | end 99 | 100 | data 101 | end 102 | 103 | def create_page(title, content) 104 | edit(title: title, text: content) 105 | end 106 | 107 | def delete_page(title, reason) 108 | action(:delete, title: title, reason: reason) 109 | end 110 | 111 | def edit(params = {}) 112 | response = action(:edit, params) 113 | raise EditError, response if response.data['result'] == 'Failure' 114 | response 115 | end 116 | 117 | def get_wikitext(title) 118 | @conn.get '/w/index.php', action: 'raw', title: title 119 | end 120 | 121 | def list(type, params = {}) 122 | subquery(:list, type, params) 123 | end 124 | 125 | def log_in(username, password, token = nil) 126 | params = { lgname: username, lgpassword: password, token_type: false } 127 | params[:lgtoken] = token unless token.nil? 128 | 129 | data = action(:login, params).data 130 | 131 | case data['result'] 132 | when 'Success' 133 | @logged_in = true 134 | @tokens.clear 135 | when 'NeedToken' 136 | raise LoginError, "failed to log in with the returned token '#{token}'" unless token.nil? 137 | data = log_in(username, password, data['token']) 138 | else 139 | raise LoginError, data['result'] 140 | end 141 | 142 | data 143 | end 144 | 145 | def meta(type, params = {}) 146 | subquery(:meta, type, params) 147 | end 148 | 149 | def prop(type, params = {}) 150 | subquery(:prop, type, params) 151 | end 152 | 153 | def protect_page(title, reason, protections = 'edit=sysop|move=sysop') 154 | action(:protect, title: title, reason: reason, protections: protections) 155 | end 156 | 157 | def query(params = {}) 158 | action(:query, { token_type: false, http_method: :get }.merge(params)) 159 | end 160 | 161 | def unwatch_page(title) 162 | action(:watch, token_type: 'watch', titles: title, unwatch: true) 163 | end 164 | 165 | def upload_image(filename, path, comment, ignorewarnings, text = nil) 166 | file = Faraday::UploadIO.new(path, 'image/png') 167 | action(:upload, 168 | filename: filename, file: file, comment: comment, text: text, 169 | ignorewarnings: ignorewarnings) 170 | end 171 | 172 | def watch_page(title) 173 | action(:watch, token_type: 'watch', titles: title) 174 | end 175 | 176 | protected 177 | 178 | def compile_parameters(parameters) 179 | parameters.each.with_object({}) do |(name, value), params| 180 | case value 181 | when false 182 | # omit it entirely 183 | when Array 184 | params[name] = value.join('|') 185 | else 186 | params[name] = value 187 | end 188 | end 189 | end 190 | 191 | def get_token(type) 192 | unless @tokens.include?(type) 193 | response = query(meta: 'tokens', type: type) 194 | parameter_warning = /Unrecognized value for parameter 'type'/ 195 | 196 | if response.warnings? && response.warnings.grep(parameter_warning).any? 197 | raise TokenError, response.warnings.join(', ') 198 | end 199 | 200 | @tokens[type] = response.data['tokens']["#{type}token"] 201 | end 202 | 203 | @tokens[type] 204 | end 205 | 206 | def send_request(method, params, envelope) 207 | response = @conn.send(method, '', params) 208 | 209 | raise HttpError, response.status if response.status >= 400 210 | 211 | if response.headers.include?('mediawiki-api-error') 212 | raise ApiError, Response.new(response, ['error']) 213 | end 214 | 215 | Response.new(response, envelope) 216 | end 217 | 218 | def subquery(type, subtype, params = {}) 219 | query(params.merge(type.to_sym => subtype, :envelope => ['query', subtype])) 220 | end 221 | 222 | def raw_action(name, params = {}) 223 | name = name.to_s 224 | params = params.clone 225 | 226 | method = params.delete(:http_method) || :post 227 | token_type = params.delete(:token_type) 228 | envelope = (params.delete(:envelope) || [name]).map(&:to_s) 229 | 230 | params[:token] = get_token(token_type || :csrf) unless token_type == false 231 | params = compile_parameters(params) 232 | 233 | send_request(method, params.merge(action: name, format: FORMAT), envelope) 234 | end 235 | end 236 | end 237 | -------------------------------------------------------------------------------- /lib/mediawiki_api/exceptions.rb: -------------------------------------------------------------------------------- 1 | module MediawikiApi 2 | # generic MediaWiki api errors 3 | class ApiError < StandardError 4 | attr_reader :response 5 | 6 | def initialize(response = nil) 7 | @response = response 8 | end 9 | 10 | def code 11 | response_data['code'] || '000' 12 | end 13 | 14 | def info 15 | response_data['info'] || 'unknown API error' 16 | end 17 | 18 | def to_s 19 | "#{info} (#{code})" 20 | end 21 | 22 | private 23 | 24 | def response_data 25 | if @response 26 | @response.data || {} 27 | else 28 | {} 29 | end 30 | end 31 | end 32 | 33 | class CreateAccountError < StandardError 34 | end 35 | 36 | # for errors from HTTP requests 37 | class HttpError < StandardError 38 | attr_reader :status 39 | 40 | def initialize(status) 41 | @status = status 42 | end 43 | 44 | def to_s 45 | "unexpected HTTP response (#{status})" 46 | end 47 | end 48 | 49 | # for edit failures 50 | class EditError < ApiError 51 | def to_s 52 | 'check the response data for details' 53 | end 54 | end 55 | 56 | class LoginError < StandardError 57 | end 58 | 59 | class TokenError < StandardError 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/mediawiki_api/response.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | require 'json' 3 | 4 | module MediawikiApi 5 | # Provides access to a parsed MediaWiki API responses. 6 | # 7 | # Some types of responses, depending on the action, contain a level or two 8 | # of addition structure (an envelope) above the actual payload. The {#data} 9 | # method provides a way of easily getting at it. 10 | # 11 | # @example 12 | # # http.body => '{"query": {"userinfo": {"some": "data"}}}' 13 | # response = Response.new(http, ["query", "userinfo"]) 14 | # response.data # => { "some" => "data" } 15 | # 16 | class Response 17 | extend Forwardable 18 | 19 | def_delegators :@response, :status, :success? 20 | 21 | # Constructs a new response. 22 | # 23 | # @param response [Faraday::Response] 24 | # @param envelope [Array] Property names for expected payload nesting. 25 | # 26 | def initialize(response, envelope = []) 27 | @response = response 28 | @envelope = envelope 29 | end 30 | 31 | # Accessor for root response object values. 32 | # 33 | # @param key [String] 34 | # 35 | # @return [Object] 36 | # 37 | def [](key) 38 | response_object[key] 39 | end 40 | 41 | # The main payload from the parsed response, removed from its envelope. 42 | # 43 | # @return [Object] 44 | # 45 | def data 46 | case response_object 47 | when Hash 48 | open_envelope(response_object) 49 | else 50 | response_object 51 | end 52 | end 53 | 54 | # Set of error messages from the response. 55 | # 56 | # @return [Array] 57 | # 58 | def errors 59 | flatten_resp('errors') 60 | end 61 | 62 | # Set of warning messages from the response. 63 | # 64 | # @return [Array] 65 | # 66 | def warnings 67 | flatten_resp('warnings') 68 | end 69 | 70 | # Whether the response contains warnings. 71 | # 72 | # @return [true, false] 73 | # 74 | def warnings? 75 | !warnings.empty? 76 | end 77 | 78 | private 79 | 80 | def flatten_resp(str) 81 | if response_object[str] 82 | response_object[str].values.map(&:values).flatten 83 | else 84 | [] 85 | end 86 | end 87 | 88 | def open_envelope(obj, env = @envelope) 89 | if !obj.is_a?(Hash) || env.nil? || env.empty? || !obj.include?(env.first) 90 | obj 91 | else 92 | open_envelope(obj[env.first], env[1..]) 93 | end 94 | end 95 | 96 | def response_object 97 | @response_object ||= JSON.parse(@response.body) 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/mediawiki_api/version.rb: -------------------------------------------------------------------------------- 1 | # MediaWiki Ruby API 2 | module MediawikiApi 3 | VERSION = '0.9.0' 4 | end 5 | -------------------------------------------------------------------------------- /mediawiki_api.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'mediawiki_api/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'mediawiki_api' 7 | spec.version = MediawikiApi::VERSION 8 | spec.authors = [ 9 | 'Amir Aharoni', 'Asaf Bartov', 'Chris McMahon', 'Dan Duvall', 'Jeff Hall', 'Juliusz Gonera', 10 | 'Zeljko Filipin' 11 | ] 12 | spec.email = [ 13 | 'amir.aharoni@mail.huji.ac.il', 'asaf.bartov@gmail.com', 'cmcmahon@wikimedia.org', 14 | 'dduvall@wikimedia.org', 'jhall@wikimedia.org', 'jgonera@wikimedia.org', 15 | 'zeljko.filipin@gmail.com' 16 | ] 17 | spec.summary = 'A library for interacting with MediaWiki API from Ruby.' 18 | spec.description = 'Uses adapter-agnostic Faraday gem to talk to MediaWiki API.' 19 | spec.homepage = 'https://github.com/wikimedia/mediawiki-ruby-api' 20 | spec.license = 'GPL-2.0' 21 | 22 | spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) 23 | spec.test_files = spec.files.grep(/^(test|spec|features)/) 24 | spec.require_paths = ['lib'] 25 | 26 | spec.required_ruby_version = '>= 2.6.0' 27 | 28 | spec.add_runtime_dependency 'faraday', '>= 2.7.0' 29 | spec.add_runtime_dependency 'faraday-multipart' 30 | spec.add_runtime_dependency 'faraday-retry' 31 | spec.add_runtime_dependency 'faraday-cookie_jar' 32 | spec.add_runtime_dependency 'faraday-follow_redirects' 33 | 34 | # Most developer dependencies can float to latest, but stick to RSpec 3 35 | # since that would likely introduce breaking changes (bundler, rubocop 36 | # and rake have excellent back-compat) 37 | spec.add_development_dependency 'bundler' 38 | spec.add_development_dependency 'rake' 39 | spec.add_development_dependency 'rspec', '~> 3' 40 | spec.add_development_dependency 'rubocop' 41 | spec.add_development_dependency 'rubocop-rake' 42 | spec.add_development_dependency 'rubocop-rspec' 43 | spec.add_development_dependency 'webmock' 44 | spec.add_development_dependency 'redcarpet' 45 | spec.add_development_dependency 'yard' 46 | end 47 | -------------------------------------------------------------------------------- /spec/client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'webmock/rspec' 3 | require 'support/request_helpers' 4 | 5 | describe MediawikiApi::Client do 6 | include MediawikiApi::RequestHelpers 7 | 8 | let(:client) { MediawikiApi::Client.new(api_url) } 9 | 10 | subject { client } 11 | 12 | body_base = { cookieprefix: 'prefix', sessionid: '123' } 13 | 14 | describe '#action' do 15 | subject { client.action(action, params) } 16 | 17 | let(:action) { 'something' } 18 | let(:token_type) { 'csrf' } 19 | let(:params) { {} } 20 | 21 | let(:response) do 22 | { status: response_status, headers: response_headers, body: response_body.to_json } 23 | end 24 | 25 | let(:response_status) { 200 } 26 | let(:response_headers) { nil } 27 | let(:response_body) { { 'something' => {} } } 28 | 29 | let(:token_warning) { nil } 30 | 31 | before do 32 | @token_request = stub_token_request(token_type, token_warning) 33 | @request = stub_api_request(:post, action: action, token: mock_token).to_return(response) 34 | end 35 | 36 | it { is_expected.to be_a(MediawikiApi::Response) } 37 | 38 | it 'makes requests for both the right token and API action' do 39 | subject 40 | expect(@token_request).to have_been_made 41 | expect(@request).to have_been_made 42 | end 43 | 44 | context 'without a required token' do 45 | let(:params) { { token_type: false } } 46 | 47 | before do 48 | @request_with_token = @request 49 | @request_without_token = stub_api_request(:post, action: action).to_return(response) 50 | end 51 | 52 | it 'does not request a token' do 53 | subject 54 | expect(@token_request).to_not have_been_made 55 | end 56 | 57 | it 'makes the action request without a token' do 58 | subject 59 | expect(@request_without_token).to have_been_made 60 | expect(@request_with_token).to_not have_been_made 61 | end 62 | end 63 | 64 | context 'given parameters' do 65 | let(:params) { { foo: 'value' } } 66 | 67 | before do 68 | @request_with_parameters = stub_action_request(action, foo: 'value').to_return(response) 69 | end 70 | 71 | it 'includes them' do 72 | subject 73 | expect(@request_with_parameters).to have_been_made 74 | end 75 | end 76 | 77 | context 'parameter compilation' do 78 | context 'negated parameters' do 79 | let(:params) { { foo: false } } 80 | 81 | before do 82 | @request_with_parameter = stub_action_request(action, foo: false).to_return(response) 83 | @request_without_parameter = stub_action_request(action).to_return(response) 84 | end 85 | 86 | it 'omits the parameter' do 87 | subject 88 | expect(@request_with_parameter).to_not have_been_made 89 | expect(@request_without_parameter).to have_been_made 90 | end 91 | end 92 | 93 | context 'array parameters' do 94 | let(:params) { { foo: %w[one two] } } 95 | 96 | before do 97 | @request = stub_action_request(action, foo: 'one|two').to_return(response) 98 | end 99 | 100 | it 'pipe delimits values' do 101 | subject 102 | expect(@request).to have_been_made 103 | end 104 | end 105 | end 106 | 107 | context 'when the response status is in the 400 range' do 108 | let(:response_status) { 403 } 109 | 110 | it 'raises an HttpError' do 111 | expect { subject }.to raise_error(MediawikiApi::HttpError, 112 | 'unexpected HTTP response (403)') 113 | end 114 | end 115 | 116 | context 'when the response status is in the 500 range' do 117 | let(:response_status) { 502 } 118 | 119 | it 'raises an HttpError' do 120 | expect { subject }.to raise_error(MediawikiApi::HttpError, 121 | 'unexpected HTTP response (502)') 122 | end 123 | end 124 | 125 | context 'when the response is an error' do 126 | let(:response_headers) { { 'MediaWiki-API-Error' => 'code' } } 127 | let(:response_body) { { error: { info: 'detailed message', code: 'code' } } } 128 | 129 | it 'raises an ApiError' do 130 | expect { subject }.to raise_error(MediawikiApi::ApiError, 'detailed message (code)') 131 | end 132 | end 133 | 134 | context 'given a bad token type' do 135 | let(:params) { { token_type: token_type } } 136 | let(:token_type) { 'badtoken' } 137 | let(:token_warning) { "Unrecognized value for parameter 'type': badtoken" } 138 | 139 | it 'raises a TokenError' do 140 | expect { subject }.to raise_error(MediawikiApi::TokenError, token_warning) 141 | end 142 | end 143 | 144 | context 'when an OAuth access token was supplied' do 145 | before do 146 | client.oauth_access_token('my_token') 147 | end 148 | 149 | it 'includes the OAuth access token in an Authorization: Bearer header' do 150 | stub_token_request('csrf') 151 | request = stub_action_request('foo').with(headers: { 'Authorization' => 'Bearer my_token' }) 152 | 153 | client.action(:foo) 154 | 155 | expect(request).to have_been_requested 156 | end 157 | end 158 | 159 | context 'when the token response includes only other types of warnings (see bug 70066)' do 160 | let(:token_warning) do 161 | 'action=tokens has been deprecated. Please use action=query&meta=tokens instead.' 162 | end 163 | 164 | it 'raises no exception' do 165 | expect { subject }.to_not raise_error 166 | end 167 | end 168 | 169 | context 'when the token is invalid' do 170 | let(:response_headers) { { 'MediaWiki-API-Error' => 'badtoken' } } 171 | let(:response_body) { { error: { code: 'badtoken', info: 'Invalid token' } } } 172 | 173 | before do 174 | # Stub a second request without the error 175 | @request.then.to_return(status: 200) 176 | end 177 | 178 | it 'rescues the initial exception' do 179 | expect { subject }.to_not raise_error 180 | end 181 | 182 | it 'automatically retries the request' do 183 | subject 184 | expect(@token_request).to have_been_made.twice 185 | expect(@request).to have_been_made.twice 186 | end 187 | end 188 | end 189 | 190 | describe '#cookies' do 191 | subject { client.cookies } 192 | 193 | it { is_expected.to be_a(HTTP::CookieJar) } 194 | 195 | context 'when a new cookie is added' do 196 | before do 197 | client.cookies.add(HTTP::Cookie.new('cookie_name', '1', domain: 'localhost', path: '/')) 198 | end 199 | 200 | it 'includes the cookie in subsequent requests' do 201 | stub_token_request('csrf') 202 | request = stub_action_request('foo').with(headers: { 'Cookie' => 'cookie_name=1' }) 203 | 204 | client.action(:foo) 205 | 206 | expect(request).to have_been_requested 207 | end 208 | end 209 | end 210 | 211 | describe '#log_in' do 212 | it 'logs in when API returns Success' do 213 | stub_request(:post, api_url). 214 | with(body: { format: 'json', action: 'login', lgname: 'Test', lgpassword: 'qwe123' }). 215 | to_return(body: { login: body_base.merge(result: 'Success') }.to_json) 216 | 217 | subject.log_in 'Test', 'qwe123' 218 | expect(subject.logged_in).to be true 219 | end 220 | 221 | context 'when API returns NeedToken' do 222 | context 'and a token was not given' do 223 | before do 224 | stub_login_request('Test', 'qwe123'). 225 | to_return( 226 | body: { login: body_base.merge(result: 'NeedToken', token: '456') }.to_json, 227 | headers: { 'Set-Cookie' => 'prefixSession=789; path=/; domain=localhost; HttpOnly' } 228 | ) 229 | 230 | @success_req = stub_login_request('Test', 'qwe123', '456'). 231 | with(headers: { 'Cookie' => 'prefixSession=789' }). 232 | to_return(body: { login: body_base.merge(result: 'Success') }.to_json) 233 | end 234 | 235 | it 'logs in' do 236 | response = subject.log_in('Test', 'qwe123') 237 | 238 | expect(response).to include('result' => 'Success') 239 | expect(subject.logged_in).to be true 240 | end 241 | 242 | it 'sends second request with token and cookies' do 243 | subject.log_in('Test', 'qwe123') 244 | 245 | expect(@success_req).to have_been_requested 246 | end 247 | end 248 | 249 | context 'but a token was already provided' do 250 | subject { client.log_in('Test', 'qwe123', '123') } 251 | 252 | it 'should raise a LoginError' do 253 | stub_login_request('Test', 'qwe123', '123'). 254 | to_return(body: { login: body_base.merge(result: 'NeedToken', token: '456') }.to_json) 255 | 256 | expect { subject }.to raise_error(MediawikiApi::LoginError) 257 | end 258 | end 259 | end 260 | 261 | context 'when API returns neither Success nor NeedToken' do 262 | before do 263 | stub_login_request('Test', 'qwe123'). 264 | to_return(body: { login: body_base.merge(result: 'EmptyPass') }.to_json) 265 | end 266 | 267 | it 'does not log in' do 268 | expect { subject.log_in 'Test', 'qwe123' }.to raise_error(MediawikiApi::LoginError) 269 | expect(subject.logged_in).to be false 270 | end 271 | 272 | it 'raises error with proper message' do 273 | expect { subject.log_in 'Test', 'qwe123' }.to raise_error(MediawikiApi::LoginError, 274 | 'EmptyPass') 275 | end 276 | end 277 | end 278 | 279 | describe '#create_page' do 280 | subject { client.create_page(title, text) } 281 | 282 | let(:title) { 'Test' } 283 | let(:text) { 'test123' } 284 | let(:response) { {} } 285 | 286 | before do 287 | stub_token_request('csrf') 288 | @edit_request = stub_action_request(:edit, title: title, text: text). 289 | to_return(body: response.to_json) 290 | end 291 | 292 | it 'makes the right request' do 293 | subject 294 | expect(@edit_request).to have_been_requested 295 | end 296 | end 297 | 298 | describe '#delete_page' do 299 | before do 300 | stub_request(:get, api_url). 301 | with(query: { format: 'json', action: 'query', meta: 'tokens', type: 'csrf' }). 302 | to_return(body: { query: { tokens: { csrftoken: 't123' } } }.to_json) 303 | @delete_req = stub_request(:post, api_url). 304 | with(body: { format: 'json', action: 'delete', 305 | title: 'Test', reason: 'deleting', token: 't123' }) 306 | end 307 | 308 | it 'deletes a page using a delete token' do 309 | subject.delete_page('Test', 'deleting') 310 | expect(@delete_req).to have_been_requested 311 | end 312 | 313 | # evaluate results 314 | end 315 | 316 | describe '#edit' do 317 | subject { client.edit(params) } 318 | 319 | let(:params) { {} } 320 | let(:response) { { edit: {} } } 321 | 322 | before do 323 | stub_token_request('csrf') 324 | @edit_request = stub_action_request(:edit).to_return(body: response.to_json) 325 | end 326 | 327 | it 'makes the request' do 328 | subject 329 | expect(@edit_request).to have_been_requested 330 | end 331 | 332 | context 'upon an edit failure' do 333 | let(:response) { { edit: { result: 'Failure' } } } 334 | 335 | it 'raises an EditError' do 336 | expect { subject }.to raise_error(MediawikiApi::EditError) 337 | end 338 | end 339 | end 340 | 341 | describe '#get_wikitext' do 342 | before do 343 | @get_req = stub_request(:get, index_url).with(query: { action: 'raw', title: 'Test' }) 344 | end 345 | 346 | it 'fetches a page' do 347 | subject.get_wikitext('Test') 348 | expect(@get_req).to have_been_requested 349 | end 350 | end 351 | 352 | describe '#create_account' do 353 | context 'when the old createaccount API is used' do 354 | before do 355 | stub_request(:post, api_url). 356 | with(body: { action: 'paraminfo', format: 'json', modules: 'createaccount' }). 357 | to_return(body: { paraminfo: body_base.merge(modules: [{ parameters: [] }]) }.to_json) 358 | end 359 | 360 | it 'creates an account when API returns Success' do 361 | stub_request(:post, api_url). 362 | with(body: { format: 'json', action: 'createaccount', name: 'Test', password: 'qwe123' }). 363 | to_return(body: { createaccount: body_base.merge(result: 'Success') }.to_json) 364 | 365 | expect(subject.create_account('Test', 'qwe123')).to include('result' => 'Success') 366 | end 367 | 368 | context 'when API returns NeedToken' do 369 | before do 370 | stub_request(:post, api_url). 371 | with(body: { format: 'json', action: 'createaccount', 372 | name: 'Test', password: 'qwe123' }). 373 | to_return( 374 | body: { createaccount: body_base.merge(result: 'NeedToken', token: '456') }.to_json, 375 | headers: { 'Set-Cookie' => 'prefixSession=789; path=/; domain=localhost; HttpOnly' } 376 | ) 377 | 378 | @success_req = stub_request(:post, api_url). 379 | with(body: { format: 'json', action: 'createaccount', 380 | name: 'Test', password: 'qwe123', token: '456' }). 381 | with(headers: { 'Cookie' => 'prefixSession=789' }). 382 | to_return(body: { createaccount: body_base.merge(result: 'Success') }.to_json) 383 | end 384 | 385 | it 'creates an account' do 386 | expect(subject.create_account('Test', 'qwe123')).to include('result' => 'Success') 387 | end 388 | 389 | it 'sends second request with token and cookies' do 390 | subject.create_account 'Test', 'qwe123' 391 | expect(@success_req).to have_been_requested 392 | end 393 | end 394 | 395 | # docs don't specify other results, but who knows 396 | # http://www.mediawiki.org/wiki/API:Account_creation 397 | context 'when API returns neither Success nor NeedToken' do 398 | before do 399 | stub_request(:post, api_url). 400 | with(body: { format: 'json', action: 'createaccount', 401 | name: 'Test', password: 'qwe123' }). 402 | to_return(body: { createaccount: body_base.merge(result: 'WhoKnows') }.to_json) 403 | end 404 | 405 | it 'raises error with proper message' do 406 | expect { subject.create_account 'Test', 'qwe123' }.to raise_error( 407 | MediawikiApi::CreateAccountError, 408 | 'WhoKnows' 409 | ) 410 | end 411 | end 412 | end 413 | 414 | context 'when the new createaccount API is used' do 415 | before do 416 | stub_request(:post, api_url). 417 | with(body: { action: 'paraminfo', format: 'json', modules: 'createaccount' }). 418 | to_return(body: { paraminfo: body_base.merge( 419 | modules: [{ parameters: [{ name: 'requests' }] }] 420 | ) }.to_json) 421 | end 422 | 423 | it 'raises an error when fetching a token fails' do 424 | stub_request(:post, api_url). 425 | with(body: { action: 'query', format: 'json', meta: 'tokens', type: 'createaccount' }). 426 | to_return(body: { tokens: body_base.merge(foo: '12345\\+') }.to_json) 427 | expect { subject.create_account 'Test', 'qwe123' }.to raise_error( 428 | MediawikiApi::CreateAccountError, 429 | 'failed to get createaccount API token' 430 | ) 431 | end 432 | 433 | context 'when fetching a token succeeds' do 434 | before do 435 | stub_request(:post, api_url). 436 | with(body: { format: 'json', action: 'query', meta: 'tokens', type: 'createaccount' }). 437 | to_return(body: { tokens: body_base.merge(createaccounttoken: '12345\\+') }.to_json) 438 | end 439 | 440 | it 'creates an account when the API returns success' do 441 | stub_request(:post, api_url). 442 | with(body: { format: 'json', action: 'createaccount', 443 | createreturnurl: 'http://example.com', username: 'Test', 444 | password: 'qwe123', retype: 'qwe123', createtoken: '12345\\+' }). 445 | to_return(body: { createaccount: body_base.merge(status: 'PASS') }.to_json) 446 | expect(subject.create_account('Test', 'qwe123')).to include('status' => 'PASS') 447 | end 448 | 449 | it 'raises an error when the API returns failure' do 450 | stub_request(:post, api_url). 451 | with(body: { format: 'json', action: 'createaccount', 452 | createreturnurl: 'http://example.com', username: 'Test', 453 | password: 'qwe123', retype: 'qwe123', createtoken: '12345\\+' }). 454 | to_return(body: { createaccount: body_base.merge( 455 | status: 'FAIL', message: 'User exists!' 456 | ) }.to_json) 457 | expect { subject.create_account 'Test', 'qwe123' }.to raise_error( 458 | MediawikiApi::CreateAccountError, 459 | 'User exists!' 460 | ) 461 | end 462 | end 463 | end 464 | 465 | it 'raises an error when the paraminfo query result is weird' do 466 | stub_request(:post, api_url). 467 | with(body: { action: 'paraminfo', format: 'json', modules: 'createaccount' }). 468 | to_return(body: { paraminfo: body_base.merge(modules: []) }.to_json) 469 | expect { subject.create_account 'Test', 'qwe123' }.to raise_error( 470 | MediawikiApi::CreateAccountError, 471 | 'unexpected API response format' 472 | ) 473 | end 474 | end 475 | 476 | describe '#watch_page' do 477 | before do 478 | stub_request(:get, api_url). 479 | with(query: { format: 'json', action: 'query', meta: 'tokens', type: 'watch' }). 480 | to_return(body: { query: { tokens: { watchtoken: 't123' } } }.to_json) 481 | @watch_req = stub_request(:post, api_url). 482 | with(body: { format: 'json', token: 't123', action: 'watch', titles: 'Test' }) 483 | end 484 | 485 | it 'sends a valid watch request' do 486 | subject.watch_page('Test') 487 | expect(@watch_req).to have_been_requested 488 | end 489 | end 490 | 491 | describe '#unwatch_page' do 492 | before do 493 | stub_request(:get, api_url). 494 | with(query: { format: 'json', action: 'query', meta: 'tokens', type: 'watch' }). 495 | to_return(body: { query: { tokens: { watchtoken: 't123' } } }.to_json) 496 | @watch_req = stub_request(:post, api_url). 497 | with(body: { format: 'json', token: 't123', action: 'watch', 498 | titles: 'Test', unwatch: 'true' }) 499 | end 500 | 501 | it 'sends a valid unwatch request' do 502 | subject.unwatch_page('Test') 503 | expect(@watch_req).to have_been_requested 504 | end 505 | end 506 | end 507 | -------------------------------------------------------------------------------- /spec/exceptions_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module MediawikiApi 4 | describe ApiError do 5 | def mock_error_response(data = {}) 6 | instance_double(Response, data: data) 7 | end 8 | 9 | describe '#code' do 10 | it 'returns the code from `error/code` in the response' do 11 | error = ApiError.new(mock_error_response('code' => '123')) 12 | 13 | expect(error.code).to eq('123') 14 | end 15 | 16 | it 'defaults to "000" when a code is not present in the response' do 17 | error = ApiError.new(mock_error_response) 18 | 19 | expect(error.code).to eq('000') 20 | end 21 | 22 | it 'defaults to "000" when a response is not provided' do 23 | error = ApiError.new 24 | 25 | expect(error.code).to eq('000') 26 | end 27 | end 28 | 29 | describe '#info' do 30 | it 'returns the info from `error/info` in the response' do 31 | error = ApiError.new(mock_error_response('info' => 'some error')) 32 | 33 | expect(error.info).to eq('some error') 34 | end 35 | 36 | it 'defaults to "unknown API error" when info is not present in the response' do 37 | error = ApiError.new(mock_error_response) 38 | 39 | expect(error.info).to eq('unknown API error') 40 | end 41 | 42 | it 'defaults to "unknown API error" when a response is not provided' do 43 | error = ApiError.new 44 | 45 | expect(error.info).to eq('unknown API error') 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/response_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe MediawikiApi::Response do 4 | let(:response) { MediawikiApi::Response.new(faraday_response, envelope) } 5 | 6 | let(:faraday_response) { double('Faraday::Response', body: body) } 7 | let(:body) { '{}' } 8 | let(:response_object) { JSON.parse(body) } 9 | let(:envelope) { [] } 10 | 11 | describe '#data' do 12 | subject { response.data } 13 | 14 | context 'with a JSON object response body' do 15 | let(:body) { '{ "query": { "result": "success" } }' } 16 | 17 | context 'and no expected envelope' do 18 | let(:envelope) { [] } 19 | 20 | it { is_expected.to eq(response_object) } 21 | end 22 | 23 | context 'and a single-level envelope' do 24 | let(:envelope) { ['query'] } 25 | let(:nested_object) { response_object['query'] } 26 | 27 | it { is_expected.to eq(nested_object) } 28 | end 29 | 30 | context 'and a multi-level envelope' do 31 | let(:envelope) { %w[query result] } 32 | let(:nested_object) { response_object['query']['result'] } 33 | 34 | it { is_expected.to eq(nested_object) } 35 | end 36 | 37 | context "and a multi-level envelope that doesn't completely match" do 38 | let(:envelope) { %w[query something] } 39 | let(:partially_nested_object) { response_object['query'] } 40 | 41 | it { is_expected.to eq(partially_nested_object) } 42 | end 43 | end 44 | 45 | context 'with a JSON array response body' do 46 | let(:body) { '[ "something" ]' } 47 | 48 | context 'with any expected envelope' do 49 | let(:envelope) { %w[what ever] } 50 | 51 | it { is_expected.to eq(response_object) } 52 | end 53 | end 54 | end 55 | 56 | describe '#warnings' do 57 | subject { response.warnings } 58 | 59 | context 'where the response contains no warnings' do 60 | let(:body) { '{ "query": { "result": "success" } }' } 61 | 62 | it { is_expected.to be_empty } 63 | end 64 | 65 | context 'where the response contains warnings' do 66 | let(:body) { '{ "warnings": { "main": { "*": "sorta bad message" } } }' } 67 | 68 | it { is_expected.to_not be_empty } 69 | it { is_expected.to include('sorta bad message') } 70 | end 71 | end 72 | 73 | describe '#warnings?' do 74 | subject { response.warnings? } 75 | 76 | before { allow(response).to receive(:warnings) { warnings } } 77 | 78 | context 'where there are warnings' do 79 | let(:warnings) { ['warning'] } 80 | 81 | it { is_expected.to be(true) } 82 | end 83 | 84 | context 'where there are no warnings' do 85 | let(:warnings) { [] } 86 | 87 | it { is_expected.to be(false) } 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | Bundler.require(:default, :development) 3 | -------------------------------------------------------------------------------- /spec/support/request_helpers.rb: -------------------------------------------------------------------------------- 1 | module MediawikiApi 2 | # testing helpers 3 | module RequestHelpers 4 | def api_url 5 | 'http://localhost/api.php' 6 | end 7 | 8 | def index_url 9 | 'http://localhost/w/index.php' 10 | end 11 | 12 | def mock_token 13 | 'token123' 14 | end 15 | 16 | def stub_api_request(method, params) 17 | params = params.each.with_object({}) { |(k, v), p| p[k] = v.to_s } 18 | 19 | stub_request(method, api_url). 20 | with((method == :post ? :body : :query) => params.merge(format: 'json')) 21 | end 22 | 23 | def stub_action_request(action, params = {}) 24 | method = params.delete(:http_method) || :post 25 | 26 | stub_api_request(method, params.merge(action: action, token: mock_token)) 27 | end 28 | 29 | def stub_login_request(username, password, token = nil) 30 | params = { action: 'login', lgname: username, lgpassword: password } 31 | params[:lgtoken] = token unless token.nil? 32 | 33 | stub_api_request(:post, params) 34 | end 35 | 36 | def stub_token_request(type, warning = nil) 37 | response = { query: { tokens: { "#{type}token" => mock_token } } } 38 | response[:warnings] = { type => { '*' => [warning] } } unless warning.nil? 39 | 40 | stub_api_request(:get, action: 'query', meta: 'tokens', type: type). 41 | to_return(body: response.to_json) 42 | end 43 | end 44 | end 45 | --------------------------------------------------------------------------------