├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── config └── cucumber.yml ├── cucumber-api.gemspec ├── features ├── sample.feature ├── schemas │ └── topstories.json └── support │ └── env.rb └── lib ├── cucumber-api.rb └── cucumber-api ├── helpers.rb ├── response.rb ├── steps.rb └── version.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /test/tmp/ 9 | /test/version_tmp/ 10 | /tmp/ 11 | 12 | ## Specific to RubyMotion: 13 | .dat* 14 | .repl_history 15 | build/ 16 | 17 | ## Documentation cache and generated files: 18 | /.yardoc/ 19 | /_yardoc/ 20 | /doc/ 21 | /rdoc/ 22 | 23 | ## Environment normalisation: 24 | /.bundle/ 25 | /lib/bundler/man/ 26 | 27 | # for a library or gem, you might want to ignore these files since the code is 28 | # intended to run in multiple environments; otherwise, check them in: 29 | Gemfile.lock 30 | .ruby-version 31 | .ruby-gemset 32 | 33 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 34 | .rvmrc 35 | 36 | .DS_Store 37 | .idea 38 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | 4 | rvm: 5 | - 2.3 6 | - 2.4 7 | - ruby-head 8 | 9 | matrix: 10 | allow_failures: 11 | - ruby-head 12 | 13 | script: "cucumber -p verbose" 14 | 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [Current](https://github.com/hidroh/cucumber-api/compare/0.9...master) 4 | 5 | ## [0.9](https://github.com/hidroh/cucumber-api/compare/0.9...0.8) (Mar 25, 2021) 6 | * Replace URI.encode with CGI.escape #33 7 | 8 | ## [0.8](https://github.com/hidroh/cucumber-api/compare/0.8...0.7) (Mar 23, 2021) 9 | * Raise error when schema is expected but missing #32 10 | 11 | ## [0.7](https://github.com/hidroh/cucumber-api/compare/0.6...0.7) (Aug 29, 2018) 12 | * Allow clearing GET response cache #25 13 | 14 | ## [0.6](https://github.com/hidroh/cucumber-api/compare/0.5...0.6) (May 08, 2018) 15 | * Allow headers to use grabbed values #24 16 | 17 | ## [0.5](https://github.com/hidroh/cucumber-api/compare/0.4...0.5) (January 26, 2018) 18 | * Bump up dependencies, requires min Ruby version 2.3.0 19 | * Fix bug where grabbed values are not shared #11 20 | 21 | ## [0.4](https://github.com/hidroh/cucumber-api/compare/0.3...0.4) (November 03, 2016) 22 | * Support additional request headers 23 | * Allow specifying JSON request body with docstring 24 | * Support simple JSON response value verification 25 | * Grabbed values now persist until overridden within scenario, or when scenario exits 26 | 27 | ## [0.3](https://github.com/hidroh/cucumber-api/compare/0.2...0.3) (March 27, 2015) 28 | 29 | * Bumped up gem dependencies, relax to minor versions 30 | * Added gem license 31 | 32 | ## [0.2](https://github.com/hidroh/cucumber-api/compare/0.1...0.2) (March 07, 2015) 33 | 34 | * Added form request body 35 | * Added loading request body from file (YAML/JSON) 36 | 37 | ## [0.1](https://github.com/hidroh/cucumber-api/releases/tag/0.1) (March 01, 2015) 38 | 39 | * Initial version 40 | * Only JSON response supported 41 | * Only JSON POST request body supported -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cucumber-api 2 | [![Build Status](https://travis-ci.org/hidroh/cucumber-api.svg?branch=master)](https://travis-ci.org/hidroh/cucumber-api) [![Gem Version](https://badge.fury.io/rb/cucumber-api.svg)](http://badge.fury.io/rb/cucumber-api) [![Dependency Status](https://gemnasium.com/hidroh/cucumber-api.svg)](https://gemnasium.com/hidroh/cucumber-api) 3 | [![Code Climate](https://codeclimate.com/github/hidroh/cucumber-api/badges/gpa.svg)](https://codeclimate.com/github/hidroh/cucumber-api) [![Total Downloads](http://ruby-gem-downloads-badge.herokuapp.com/cucumber-api?type=total)](https://rubygems.org/gems/cucumber-api) 4 | 5 | API validator in BDD style with [Cucumber](https://cukes.info/). **cucumber-api** lets one validate public APIs JSON response in blazingly fast time. 6 | 7 | Inspired by [cucumber-api-steps](https://github.com/jayzes/cucumber-api-steps). 8 | 9 | Checkout [sample](/features/sample.feature) to see **cucumber-api** in action. 10 | 11 | ## Installation 12 | 13 | Add `cucumber-api` gem to your `Gemfile`: 14 | 15 | gem 'cucumber-api' 16 | 17 | Require `cucumber-api` in your Cucumber's `env.rb`: 18 | 19 | ```ruby 20 | require 'cucumber-api' 21 | ``` 22 | 23 | ## Configuration 24 | 25 | **Verbose logging:** enable verbose logging of API calls and responses by setting `cucumber_api_verbose=true` in your `ENV`, preferably via your `cucumber.yml` 26 | 27 | ```yaml 28 | # config/cucumber.yml 29 | ##YAML Template 30 | --- 31 | verbose : cucumber_api_verbose=true 32 | ``` 33 | 34 | ## Usage 35 | 36 | ### Available steps 37 | 38 | **Preparation steps** 39 | 40 | Specify your request header's `Content-Type` and `Accept`. The only supported option for `Accept` is `application/json` at the moment. 41 | 42 | ```gherkin 43 | Given I send and accept JSON 44 | Given I send "(.*?)" and accept JSON 45 | ``` 46 | 47 | You could also others header's information like: 48 | 49 | ```gherkin 50 | Given I send and accept JSON 51 | And I add Headers: 52 | | name1 | value | 53 | | name2 | other | 54 | ``` 55 | 56 | Specify POST body 57 | 58 | ```gherkin 59 | When I set JSON request body to '(.*?)' 60 | When I set form request body to: 61 | | key1 | value1 | 62 | | key2 | {value2} | 63 | | key3 | file://path-to-file | 64 | When I set JSON request body to: 65 | """ 66 | { 67 | "key1": "jsonString", 68 | "key2": 1 69 | } 70 | """ 71 | ``` 72 | 73 | Or from YAML/JSON file 74 | 75 | ```gherkin 76 | When I set request body from "(.*?).(yml|json)" 77 | ``` 78 | 79 | Example: 80 | 81 | ```Gherkin 82 | Given I send "www-x-form-urlencoded" and accept JSON 83 | When I set JSON request body to '{"login": "email@example.com", "password": "password"}' 84 | When I set form request body to: 85 | | login | email@example.com | 86 | | password | password | 87 | When I set request body from "data/json-data.json" 88 | When I set request body from "data/form-data.yml" 89 | ``` 90 | 91 | **Request steps** 92 | 93 | Specify query string parameters and send an HTTP request to given URL with parameters 94 | 95 | ```gherkin 96 | When I send a (GET|POST|PATCH|PUT|DELETE) request to "(.*?)" 97 | When I send a (GET|POST|PATCH|PUT|DELETE) request to "(.*?)" with: 98 | | param1 | param2 | ... | 99 | | value1 | value2 | ... | 100 | ``` 101 | 102 | Temporarily save values from the last request to use in subsequent steps in the same scenario: 103 | 104 | ```gherkin 105 | When I grab "(.*?)" as "(.*?)" 106 | ``` 107 | 108 | Optionally, auto infer placeholder from grabbed JSON path: 109 | 110 | ```gherkin 111 | # Grab and auto assign {id} as placeholder 112 | When I grab "$..id" 113 | ``` 114 | 115 | The saved value can then be used to replace `{placeholder}` in the subsequent steps. 116 | 117 | Example: 118 | 119 | ```gherkin 120 | When I send a POST request to "http://example.com/token" 121 | And I grab "$..request_token" as "token" 122 | And I grab "$..access_type" as "type" 123 | And I grab "$..id" 124 | And I send a GET request to "http://example.com/{token}" with: 125 | | type | pretty | 126 | | {type} | true | 127 | Then the JSON response should have required key "id" of type string and value "{id}" 128 | ``` 129 | 130 | Assume that [http://example.com/token](http://example.com/token) have an element `{"request_token": 1, "access_type": "full", "id": "user1"}`, **cucumber-api** will execute the followings: 131 | 132 | * POST [http://example.com/token](http://example.com/token) 133 | * Extract the first `request_token`, `access_type` and `id` from JSON response and save it for subsequent steps 134 | * GET [http://example.com/1?type=full&pretty=true](http://example.com/1?type=full&pretty=true) 135 | * Verify that JSON response has a pair of JSON key-value: `"id": "user1"` 136 | * Clear all saved values 137 | 138 | This will be handy when one needs to make a sequence of calls to authenticate/authorize API access. 139 | 140 | **Assert steps** 141 | 142 | Verify: 143 | * HTTP response status code 144 | * JSON response against a JSON schema conforming to [JSON Schema Draft 4](http://tools.ietf.org/html/draft-zyp-json-schema-04) 145 | * Adhoc JSON response key-value type pair, where key is a [JSON path](http://goessner.net/articles/JsonPath/) 146 | 147 | ```gherkin 148 | Then the response status should be "(\d+)" 149 | Then the JSON response should follow "(.*?)" 150 | Then the JSON response root should be (object|array) 151 | Then the JSON response should have key "([^\"]*)" 152 | Then the JSON response should have (required|optional) key "(.*?)" of type (numeric|string|boolean|numeric_string|object|array|any)( or null) 153 | Then the JSON response should have (required|optional) key "(.*?)" of type (numeric|string|boolean|numeric_string|object|array|any)( or null) and value "(.*?)" 154 | ``` 155 | 156 | Example: 157 | 158 | ```gherkin 159 | Then the response status should be "200" 160 | Then the JSON response should follow "features/schemas/example_all.json" 161 | Then the JSON response root should be array 162 | Then the JSON response should have key "id" 163 | Then the JSON response should have optional key "format" of type string or null 164 | Then the JSON response should have required key "status" of type string and value "foobar" 165 | ``` 166 | 167 | Also checkout [sample](/features/sample.feature) for real examples. Run sample with the following command: 168 | 169 | ``` 170 | cucumber -p verbose 171 | ``` 172 | 173 | ### Response caching 174 | 175 | Response caching is provided for GET requests by default. This is useful when you have a Scenario Outline or multiple Scenarios that make GET requests to the same endpoint. 176 | 177 | Only the first request to that endpoint is made, subsequent requests will use cached response. Response caching is only available for GET method. 178 | 179 | The response cache can also be cleared if needed: 180 | 181 | ```gherkin 182 | Given I clear the response cache 183 | ``` 184 | 185 | ## Dependencies 186 | * [cucumber](https://github.com/cucumber/cucumber) for BDD style specs 187 | * [jsonpath](https://github.com/joshbuddy/jsonpath) for traversal of JSON response via [JSON path](http://goessner.net/articles/JsonPath/) 188 | * [json-schema](https://github.com/ruby-json-schema/json-schema) for JSON schema validation 189 | * [rest-client](https://github.com/rest-client/rest-client) for HTTP REST request 190 | -------------------------------------------------------------------------------- /config/cucumber.yml: -------------------------------------------------------------------------------- 1 | # config/cucumber.yml 2 | ##YAML Template 3 | --- 4 | verbose : cucumber_api_verbose=true 5 | -------------------------------------------------------------------------------- /cucumber-api.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "cucumber-api/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "cucumber-api" 7 | s.version = CucumberApi::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Ha Duy Trung"] 10 | s.email = ["haduytrung@gmail.com"] 11 | s.homepage = "https://github.com/hidroh/cucumber-api" 12 | s.summary = %q{API validator with Cucumber} 13 | s.description = %q{cucumber-api allows API JSON response validation and verification in BDD style.} 14 | s.files = `git ls-files`.split("\n") 15 | s.require_paths = ["lib"] 16 | s.required_ruby_version = '>= 2.3.0' 17 | s.license = 'Apache-2.0' 18 | 19 | s.add_dependency('addressable', '2.5') 20 | s.add_dependency('cucumber', '~> 3.1.0') 21 | s.add_dependency('jsonpath', '~> 0.8') 22 | s.add_dependency('rest-client', '~> 2.0.2') 23 | s.add_dependency('json-schema', '~> 2.8.0') 24 | end 25 | -------------------------------------------------------------------------------- /features/sample.feature: -------------------------------------------------------------------------------- 1 | # https://github.com/HackerNews/API 2 | Feature: Hacker News REST API validation 3 | 4 | Background: 5 | Given I send and accept JSON 6 | 7 | Scenario: Verify top stories JSON schema 8 | When I add Headers: 9 | | Cache-Control | no-cache | 10 | And I send a GET request to "https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty" 11 | Then the response status should be "200" 12 | And the JSON response should follow "features/schemas/topstories.json" 13 | 14 | Scenario Outline: Verify item JSON schema 15 | When I send a GET request to "https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty" 16 | Then the response status should be "200" 17 | And the JSON response root should be array 18 | When I grab "$[0]" as "id" 19 | And I send a GET request to "https://hacker-news.firebaseio.com/v0/item/{id}.json" with: 20 | | print | 21 | | pretty | 22 | Then the response status should be "200" 23 | And the JSON response root should be object 24 | And the JSON response should have key "" of type 25 | And the JSON response should have "id" of type numeric and value "{id}" 26 | 27 | Examples: 28 | | key | value type | optionality | 29 | | id | numeric | required | 30 | | score | numeric | required | 31 | | url | string | optional | 32 | 33 | Scenario: Demonstrate setting the JSON body with a docstring 34 | When I set JSON request body to: 35 | """ 36 | { 37 | "title": "foo", 38 | "body": "bar", 39 | "userId": 1 40 | } 41 | """ 42 | And I send a POST request to "http://jsonplaceholder.typicode.com/posts" 43 | Then the response status should be "201" 44 | And the JSON response should have "id" of type numeric and value "101" 45 | And the JSON response should have "title" of type string and value "foo" 46 | And the JSON response should have "body" of type string and value "bar" 47 | And the JSON response should have "userId" of type numeric and value "1" 48 | 49 | -------------------------------------------------------------------------------- /features/schemas/topstories.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "type": "array", 4 | "items": { 5 | "type": "number" 6 | } 7 | } -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | require 'cucumber-api' -------------------------------------------------------------------------------- /lib/cucumber-api.rb: -------------------------------------------------------------------------------- 1 | require 'cucumber-api/steps' -------------------------------------------------------------------------------- /lib/cucumber-api/helpers.rb: -------------------------------------------------------------------------------- 1 | module CucumberApi 2 | module Helpers 3 | # Bind grabbed values into placeholders in given URL 4 | # Ex: http://example.com?id={id} with {id => 1} becomes http://example.com?id=1 5 | # @param url [String] parameterized URL with placeholders 6 | # @return [String] binded URL or original URL if no placeholders 7 | def resolve url 8 | url.gsub!(/\{([a-zA-Z0-9_]+)\}/) do |s| 9 | s.gsub!(/[\{\}]/, '') 10 | if instance_variable_defined?("@#{s}") 11 | CGI.escape %/#{instance_variable_get("@#{s}")}/ 12 | else 13 | raise 'Did you forget to "grab" ' + s + '?' 14 | end 15 | end 16 | url 17 | end 18 | end 19 | end 20 | 21 | World(CucumberApi::Helpers) 22 | -------------------------------------------------------------------------------- /lib/cucumber-api/response.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'jsonpath' 3 | require 'rest-client' 4 | 5 | module CucumberApi 6 | 7 | # Extension of {RestClient::Response} with support for JSON path traversal and validation 8 | module Response 9 | # Create a Response with JSON path support 10 | # @param response [RestClient::Response] original response 11 | # @return [Response] self 12 | def self.create response 13 | result = response 14 | result.extend Response 15 | result 16 | end 17 | 18 | # Check if given JSON path exists 19 | # @param json_path [String] a valid JSON path expression 20 | # @param json [String] optional JSON from which to check JSON path, default to response body 21 | # @return [true, false] true if JSON path is valid and exists, false otherwise 22 | def has json_path, json=nil 23 | if json.nil? 24 | json = JSON.parse body 25 | end 26 | not JsonPath.new(json_path).on(json).empty? 27 | end 28 | 29 | # Retrieve value of the first JSON element with given JSON path 30 | # @param json_path [String] a valid JSON path expression 31 | # @param json [String] optional JSON from which to apply JSON path, default to response body 32 | # @return [Object] value of first retrieved JSON element in form of Ruby object 33 | # @raise [Exception] if JSON path is invalid or no matching JSON element found 34 | def get json_path, json=nil 35 | if json.nil? 36 | json = JSON.parse body 37 | end 38 | results = JsonPath.new(json_path).on(json) 39 | if results.empty? 40 | raise %/Expected json path '#{json_path}' not found\n#{to_json_s}/ 41 | end 42 | results.first 43 | end 44 | 45 | # Retrieve value of the first JSON element with given JSON path as given type 46 | # @param json_path [String] a valid JSON path expression 47 | # @param type [String] required type, possible values are 'numeric', 'array', 'string', 'boolean', 'numeric_string' 48 | # or 'object' 49 | # @param json [String] optional JSON from which to apply JSON path, default to response body 50 | # @return [Object] value of first retrieved JSON element in form of given type 51 | # @raise [Exception] if JSON path is invalid or no matching JSON element found or matching element does not match 52 | # required type 53 | def get_as_type json_path, type, json=nil 54 | value = get json_path, json 55 | case type 56 | when 'numeric' 57 | valid = value.is_a? Numeric 58 | when 'array' 59 | valid = value.is_a? Array 60 | when 'string' 61 | valid = value.is_a? String 62 | when 'boolean' 63 | valid = !!value == value 64 | when 'numeric_string' 65 | valid = value.is_a?(Numeric) or value.is_a?(String) 66 | when 'object' 67 | valid = value.is_a? Hash 68 | else 69 | raise %/Invalid expected type '#{type}'/ 70 | end 71 | 72 | unless valid 73 | raise %/Expect '#{json_path}' as a '#{type}' but was '#{value.class}'\n#{to_json_s}/ 74 | end 75 | value 76 | end 77 | 78 | # Retrieve value of the first JSON element with given JSON path as given type, with nil value allowed 79 | # @param json_path [String] a valid JSON path expression 80 | # @param type [String] required type, possible values are 'numeric', 'array', 'string', 'boolean', 'numeric_string' 81 | # or 'object' 82 | # @param json [String] optional JSON from which to apply JSON path, default to response body 83 | # @return [Object] value of first retrieved JSON element in form of given type or nil 84 | # @raise [Exception] if JSON path is invalid or no matching JSON element found or matching element does not match 85 | # required type 86 | def get_as_type_or_null json_path, type, json=nil 87 | value = get json_path, json 88 | value.nil? ? value : get_as_type(json_path, type, json) 89 | end 90 | 91 | # Retrieve value of the first JSON element with given JSON path as given type, and check for a given value 92 | # @param json_path [String] a valid JSON path expression 93 | # @param type [String] required type, possible values are 'numeric', 'string', 'boolean', or 'numeric_string' 94 | # @param value [String] value to check for 95 | # @param json [String] optional JSON from which to apply JSON path, default to response body 96 | # @return [Object] value of first retrieved JSON element in form of given type or nil 97 | # @raise [Exception] if JSON path is invalid or no matching JSON element found or matching element does not match 98 | # required type or value 99 | def get_as_type_and_check_value json_path, type, value, json=nil 100 | v = get_as_type json_path, type, json 101 | if value != v.to_s 102 | raise %/Expect '#{json_path}' to be '#{value}' but was '#{v}'\n#{to_json_s}/ 103 | end 104 | end 105 | 106 | # Retrieve pretty JSON response for logging 107 | # @return [String] pretty JSON response if verbose setting is true, empty string otherwise 108 | def to_json_s 109 | if ENV['cucumber_api_verbose'] == 'true' 110 | JSON.pretty_generate(JSON.parse to_s) 111 | else 112 | '' 113 | end 114 | end 115 | RestClient::Response.send(:include, self) 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/cucumber-api/steps.rb: -------------------------------------------------------------------------------- 1 | require 'cucumber-api/response' 2 | require 'cucumber-api/helpers' 3 | require 'rest-client' 4 | require 'json-schema' 5 | 6 | if ENV['cucumber_api_verbose'] == 'true' 7 | RestClient.log = 'stdout' 8 | end 9 | 10 | $cache = {} 11 | 12 | Given(/^I clear the response cache$/) do 13 | $cache = {} 14 | end 15 | 16 | Given(/^I send and accept JSON$/) do 17 | steps %Q{ 18 | Given I send "application/json" and accept JSON 19 | } 20 | end 21 | 22 | Given(/^I send "(.*?)" and accept JSON$/) do |content_type| 23 | @headers = { 24 | :Accept => 'application/json', 25 | :'Content-Type' => %/#{content_type}/ 26 | } 27 | end 28 | 29 | Given(/^I add Headers:$/) do |params| 30 | params.rows_hash.each do |key, value| 31 | @headers[key.to_sym] = resolve(value) 32 | end 33 | end 34 | 35 | When(/^I grab "([^"]+)"$/) do |k| 36 | if @response.nil? 37 | raise 'No response found.' 38 | end 39 | 40 | if k[0] == '$' 41 | v = k 42 | else 43 | v = "$.#{k}" 44 | end 45 | 46 | k.gsub!(/[^0-9a-zA-Z_]/, '') 47 | instance_variable_set("@#{k}", @response.get(v)) 48 | end 49 | 50 | When(/^I grab "([^"]+)" as "([^"]+)"$/) do |k, v| 51 | if @response.nil? 52 | raise 'No response found.' 53 | end 54 | 55 | k = "$.#{k}" unless k[0] == '$' 56 | instance_variable_set("@#{v}", @response.get(k)) 57 | end 58 | 59 | When(/^I set JSON request body to '(.*?)'$/) do |body| 60 | @body = JSON.parse body 61 | end 62 | 63 | When(/^I set JSON request body to:$/) do |body| 64 | @body = JSON.dump(JSON.parse(body)) 65 | end 66 | 67 | When(/^I set form request body to:$/) do |params| 68 | @body = {} 69 | params.rows_hash.each do |key, value| 70 | p_value = value 71 | @grabbed.each { |k, v| p_value = v if value == %/{#{k}}/ } unless @grabbed.nil? 72 | p_value = File.new %-#{Dir.pwd}/#{p_value.sub 'file://', ''}- if %/#{p_value}/.start_with? "file://" 73 | @body[%/#{key}/] = p_value 74 | end 75 | end 76 | 77 | When(/^I set request body from "(.*?).(yml|json)"$/) do |filename, extension| 78 | path = %-#{Dir.pwd}/#{filename}.#{extension}- 79 | if File.file? path 80 | case extension 81 | when 'yml' 82 | @body = YAML.load File.open(path) 83 | when 'json' 84 | @body = JSON.parse File.read(path) 85 | else 86 | raise %/Unsupported file type: '#{path}'/ 87 | end 88 | else 89 | raise %/File not found: '#{path}'/ 90 | end 91 | end 92 | 93 | When(/^I send a (GET|POST|PATCH|PUT|DELETE) request to "(.*?)" with:$/) do |method, url, params| 94 | unless params.hashes.empty? 95 | query = params.hashes.first.map{|key, value| %/CGI.escape #{key}=#{value}/}.join("&") 96 | url = url.include?('?') ? %/#{url}&#{query}/ : %/#{url}?#{query}/ 97 | end 98 | steps %Q{ 99 | When I send a #{method} request to "#{url}" 100 | } 101 | end 102 | 103 | When(/^I send a (GET|POST|PATCH|PUT|DELETE) request to "(.*?)"$/) do |method, url| 104 | request_url = resolve(url) 105 | if 'GET' == %/#{method}/ and $cache.has_key? %/#{request_url}/ 106 | @response = $cache[%/#{request_url}/] 107 | @headers = nil 108 | @body = nil 109 | next 110 | end 111 | 112 | @headers = {} if @headers.nil? 113 | begin 114 | case method 115 | when 'GET' 116 | response = RestClient.get request_url, @headers 117 | when 'POST' 118 | response = RestClient.post request_url, @body, @headers 119 | when 'PATCH' 120 | response = RestClient.patch request_url, @body, @headers 121 | when 'PUT' 122 | response = RestClient.put request_url, @body, @headers 123 | else 124 | response = RestClient.delete request_url, @headers 125 | end 126 | rescue RestClient::Exception => e 127 | response = e.response 128 | end 129 | @response = CucumberApi::Response.create response 130 | @headers = nil 131 | @body = nil 132 | $cache[%/#{request_url}/] = @response if 'GET' == %/#{method}/ 133 | end 134 | 135 | Then(/^the response status should be "(\d+)"$/) do |status_code| 136 | raise %/Expect #{status_code} but was #{@response.code}/ if @response.code != status_code.to_i 137 | end 138 | 139 | Then(/^the JSON response should follow "(.*?)"$/) do |schema| 140 | file_path = %-#{Dir.pwd}/#{schema}- 141 | if File.file? file_path 142 | begin 143 | JSON::Validator.validate!(file_path, @response.to_s) 144 | rescue JSON::Schema::ValidationError => e 145 | raise JSON::Schema::ValidationError.new(%/#{$!.message}\n#{@response.to_json_s}/, 146 | $!.fragments, $!.failed_attribute, $!.schema) 147 | end 148 | else 149 | raise %/Schema not found: '#{file_path}'/ 150 | end 151 | end 152 | 153 | Then(/^the JSON response root should be (object|array)$/) do |type| 154 | steps %Q{ 155 | Then the JSON response should have required key "$" of type #{type} 156 | } 157 | end 158 | 159 | Then(/^the JSON response should have key "([^\"]*)"$/) do |json_path| 160 | steps %Q{ 161 | Then the JSON response should have required key "#{json_path}" of type any 162 | } 163 | end 164 | 165 | Then(/^the JSON response should have (required|optional) key "(.*?)" of type \ 166 | (numeric|string|boolean|numeric_string|object|array|any)( or null)?$/) do |optionality, json_path, type, null_allowed| 167 | next if optionality == 'optional' and not @response.has(json_path) # if optional and no such key then skip 168 | if 'any' == type 169 | @response.get json_path 170 | elsif null_allowed.nil? 171 | @response.get_as_type json_path, type 172 | else 173 | @response.get_as_type_or_null json_path, type 174 | end 175 | end 176 | 177 | Then(/^the JSON response should have "([^"]*)" of type \ 178 | (numeric|string|boolean|numeric_string) and value "([^"]*)"$/) do |json_path, type, value| 179 | @response.get_as_type_and_check_value json_path, type, resolve(value) 180 | end 181 | -------------------------------------------------------------------------------- /lib/cucumber-api/version.rb: -------------------------------------------------------------------------------- 1 | module CucumberApi 2 | VERSION = "0.9" 3 | end 4 | --------------------------------------------------------------------------------