├── .gitignore ├── examples ├── rightscale │ ├── scenarios │ │ ├── list_servers.yml │ │ ├── delete_server.yml │ │ └── create_server.yml │ ├── config │ │ └── resat.yaml │ ├── README.rdoc │ └── additional │ │ └── run_server.yml └── twitter │ ├── output.yml │ ├── scenarios │ ├── tweet.yml │ └── timelines.yml │ ├── additional │ ├── follow.yml │ └── send_message.yml │ ├── config │ └── resat.yaml │ └── README.rdoc ├── lib ├── resat.rb ├── net_patch.rb ├── kwalify_helper.rb ├── file_set.rb ├── guard.rb ├── rdoc_patch.rb ├── handler.rb ├── variables.rb ├── config.rb ├── log.rb ├── engine.rb ├── filter.rb ├── api_request.rb └── scenario_runner.rb ├── schemas ├── variables.yaml ├── config.yaml └── scenarios.yaml ├── Rakefile ├── LICENSE ├── bin └── resat └── README.rdoc /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /examples/rightscale/scenarios/list_servers.yml: -------------------------------------------------------------------------------- 1 | # List servers in default deployment 2 | name: List servers 3 | steps: 4 | - request: 5 | operation: index 6 | resource: servers 7 | valid_codes: 8 | - 200 9 | 10 | -------------------------------------------------------------------------------- /examples/twitter/output.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: last_public_tweet 3 | value: "Novellus&#39; CoolFill CVD Process Advances Tungsten Fill for Sub-32nm ...: Logic devices, though not as aggress.. http://tinyurl.com/csja8w" 4 | - name: last_friends_tweet 5 | value: "@rgsimon Hey resat is cool!" 6 | -------------------------------------------------------------------------------- /lib/resat.rb: -------------------------------------------------------------------------------- 1 | # This file is here so Resat can be used as a Rails plugin. 2 | # Use the 'resat' application in the root bin folder to run resat from the command line. 3 | # 4 | 5 | module Resat 6 | VERSION = '0.8.0' 7 | end 8 | 9 | require File.join(File.dirname(__FILE__), 'engine') 10 | -------------------------------------------------------------------------------- /lib/net_patch.rb: -------------------------------------------------------------------------------- 1 | # Patch Net::HTTP so that SSL requests don't output: 2 | # warning: peer certificate won't be verified in this SSL session 3 | # See resat.rb for usage information. 4 | # 5 | 6 | require 'net/http' 7 | require 'net/https' 8 | 9 | module Net 10 | class HTTP 11 | def warn(*obj) 12 | end 13 | end 14 | end 15 | 16 | -------------------------------------------------------------------------------- /examples/rightscale/scenarios/delete_server.yml: -------------------------------------------------------------------------------- 1 | # Delete previously created server 2 | name: Delete Rails Server 3 | steps: 4 | - request: 5 | operation: destroy 6 | resource: servers 7 | id: $server_id 8 | valid_codes: 9 | - 200 10 | filters: 11 | - name: validate destroy response 12 | target: body 13 | is_empty: true -------------------------------------------------------------------------------- /examples/twitter/scenarios/tweet.yml: -------------------------------------------------------------------------------- 1 | # Send Tweet 2 | # http://twitter.com/statuses/update.format 3 | name: Send Tweet 4 | config: ../config/resat.yaml 5 | steps: 6 | - request: 7 | resource: statuses # Act on the 'notifications' resource 8 | custom: # Use a custom operation (i.e. not a CRUD operation) 9 | name: update.xml # Operation name 10 | type: post # POST request 11 | params: 12 | - name: status 13 | value: $tweet 14 | 15 | -------------------------------------------------------------------------------- /schemas/variables.yaml: -------------------------------------------------------------------------------- 1 | # == Synopsis 2 | # 3 | # This file contains the Kwalify YAML schema for files containing serialized 4 | # variables. 5 | # 6 | # resat scenarios may define filters with extractors that can save the 7 | # extracted value to an output file specified in the config file. 8 | # 9 | # variables are serialized into a name value pairs YAML sequence. 10 | # 11 | 12 | # Schema for serialized variables 13 | type: seq 14 | sequence: 15 | - type: map 16 | mapping: 17 | "name": { type: str, required: yes, unique: yes } 18 | "value": { type: str, required: yes } -------------------------------------------------------------------------------- /examples/twitter/additional/follow.yml: -------------------------------------------------------------------------------- 1 | # Follow username specified on command line 2 | # 3 | # Usage: 4 | # resat follow -d followed:rgsimon 5 | 6 | name: Follow given user 7 | config: ../config/resat.yaml 8 | steps: 9 | - request: 10 | resource: notifications # Act on the 'notifications' resource 11 | custom: # Use a custom operation (i.e. not a CRUD operation) 12 | name: follow # Operation name 13 | type: post # POST request 14 | id: $followed # ID of user to follow 15 | format: xml # Get response in XML format -------------------------------------------------------------------------------- /examples/rightscale/config/resat.yaml: -------------------------------------------------------------------------------- 1 | # Hostname used for API calls 2 | host: moo.rightscale.com 3 | 4 | # Port 5 | # port: 443 6 | 7 | # Common base URL to all API calls 8 | base_url: api/acct/$acct 9 | 10 | # Use HTTPS? 11 | use_ssl: yes 12 | 13 | # Basic auth username if any 14 | username: $user 15 | 16 | # Basic auth password if any 17 | password: $pass 18 | 19 | # Common request headers for all API calls 20 | headers: 21 | - name: X-API-VERSION 22 | value: '1.0' 23 | 24 | # Common parameters for all API calls 25 | params: 26 | 27 | # Global variables 28 | variables: 29 | 30 | # Input file 31 | #input: variables.yml 32 | 33 | # Output file 34 | #output: variables.yml 35 | -------------------------------------------------------------------------------- /lib/kwalify_helper.rb: -------------------------------------------------------------------------------- 1 | # Helper methods that wrap common Kwalify use case 2 | # 3 | 4 | require 'kwalify' 5 | 6 | module Resat 7 | 8 | class KwalifyHelper 9 | 10 | # Create new parser from given schema file 11 | def KwalifyHelper.new_parser(schema_file) 12 | schema = Kwalify::Yaml.load_file(schema_file) 13 | validator = Kwalify::Validator.new(schema) 14 | res = Kwalify::Yaml::Parser.new(validator) 15 | res.data_binding = true 16 | res 17 | end 18 | 19 | # Format error message from parser errors 20 | def KwalifyHelper.parser_error(parser) 21 | first = true 22 | parser.errors.inject("") do |msg, e| 23 | msg << "\n" unless first 24 | first = false if first 25 | msg << "#{e.linenum}:#{e.column} [#{e.path}] #{e.message}" 26 | end 27 | end 28 | 29 | end 30 | 31 | end -------------------------------------------------------------------------------- /examples/twitter/additional/send_message.yml: -------------------------------------------------------------------------------- 1 | # Send direct message to username specified on command line 2 | # Note: user must be following you otherwise the request returns 403. 3 | # 4 | # Usage: 5 | # resat send_message -d to:rgsimon -d text:'Hello from resat!' 6 | 7 | name: Send Direct Message 8 | config: ../config/resat.yaml 9 | steps: 10 | - request: 11 | resource: direct_messages # Act on the 'direct_messages' resource 12 | custom: # Use a custom operation (i.e. not a CRUD operation) 13 | name: new.xml # Operation name 14 | type: post # POST request 15 | params: 16 | - name: user # 'user' parameter 17 | value: $to # Username 18 | - name: text # 'text' parameter 19 | value: $text # Message content -------------------------------------------------------------------------------- /examples/twitter/config/resat.yaml: -------------------------------------------------------------------------------- 1 | # Hostname used for API calls 2 | host: twitter.com 3 | 4 | # Port 5 | # port: 443 6 | 7 | # Common base URL to all API calls 8 | base_url: 9 | 10 | # Use HTTPS? 11 | use_ssl: yes 12 | 13 | # Basic auth username if any 14 | # Uses variable 'user' from the command line 15 | username: $user 16 | 17 | # Basic auth password if any 18 | # Uses variable 'pass' from the command line 19 | password: $pass 20 | 21 | # Common request headers for all API calls 22 | headers: 23 | # - name: header_name 24 | # value: header_value 25 | 26 | # Common parameters for all API calls 27 | params: 28 | # - name: param_name 29 | # value: param_value 30 | 31 | # Global variables 32 | variables: 33 | - name: tweet 34 | value: 'Checking out resat (http://tinyurl.com/dg8gf9)' 35 | 36 | # Input file 37 | # input: input.yml 38 | 39 | # Output file 40 | output: output.yml 41 | -------------------------------------------------------------------------------- /lib/file_set.rb: -------------------------------------------------------------------------------- 1 | # Set of files with given extension in given directory 2 | # See resat.rb for usage information. 3 | # 4 | 5 | module Resat 6 | class FileSet < Array 7 | 8 | # Folders that won't be scanned for files 9 | IGNORED_FOLDERS = %w{ . .. .svn .git } 10 | 11 | # Initialize with all file names found in 'dir' and its sub-directories 12 | # with given file extensions 13 | def initialize(dir, extensions) 14 | super(0) 15 | concat(FileSet.gather_files(dir, extensions)) 16 | end 17 | 18 | def self.gather_files(dir, extensions) 19 | files = Array.new 20 | Dir.foreach(dir) do |filename| 21 | if File.directory?(filename) 22 | unless IGNORED_FOLDERS.include?(filename) 23 | files.concat(FileSet.gather_files(filename, extensions)) 24 | end 25 | elsif extensions.include?(File.extname(filename)) 26 | files << File.join(dir, filename) 27 | end 28 | end 29 | files 30 | end 31 | 32 | end 33 | end -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rake/gempackagetask' 3 | require 'lib/resat' 4 | 5 | GEM = 'resat' 6 | GEM_VER = Resat::VERSION 7 | AUTHOR = 'Raphael Simon' 8 | EMAIL = 'raphael@rightscale.com' 9 | HOMEPAGE = 'http://github.com/raphael/resat' 10 | SUMMARY = 'Web scripting for the masses' 11 | 12 | spec = Gem::Specification.new do |s| 13 | s.name = GEM 14 | s.version = GEM_VER 15 | s.author = AUTHOR 16 | s.email = EMAIL 17 | s.platform = Gem::Platform::RUBY 18 | s.summary = SUMMARY 19 | s.description = SUMMARY 20 | s.homepage = HOMEPAGE 21 | s.files = %w(LICENSE README.rdoc Rakefile) + FileList["{bin,lib,schemas,examples}/**/*"].to_a 22 | s.executables = ['resat'] 23 | s.extra_rdoc_files = ["README.rdoc", "LICENSE"] 24 | s.add_dependency("kwalify", ">= 0.7.1") 25 | end 26 | 27 | Rake::GemPackageTask.new(spec) do |pkg| 28 | pkg.gem_spec = spec 29 | end 30 | 31 | task :install => [:package] do 32 | sh %{sudo gem install pkg/#{GEM}-#{GEM_VER}} 33 | end 34 | 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 RightScale, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | 'Software'), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /examples/rightscale/README.rdoc: -------------------------------------------------------------------------------- 1 | = Synopsis 2 | 3 | This example uses the RightScale REST API: 4 | http://wiki.rightscale.com/2._References/01-RightScale/03-RightScale_API 5 | 6 | This example will list all your servers, create a new server using the 7 | 'Rails all-in-one' server template and delete it. 8 | 9 | See the main README.rdoc for instructions on how to setup resat prior to 10 | running the examples. 11 | 12 | = How to 13 | 14 | * Run: 15 | 16 | $ resat scenarios -d user: -d pass: -d acct: 17 | 18 | * See: 19 | 20 | $ cat output.yml 21 | 22 | * See more: 23 | 24 | $ vi /tmp/resat.log 25 | 26 | or 27 | 28 | $ vi resat.log 29 | 30 | if /tmp does not exist 31 | 32 | = Additional Examples 33 | 34 | The run_server example in the additional folder will create and 35 | launch a server in the default deployment and wait until it's 36 | operational before running an operational script on it. It will then stop and 37 | delete it. See the file additional/run_server.yml 38 | (http://github.com/raphael/resat/blob/master/examples/rightscale/additional/run_server.yml) 39 | for additional information. -------------------------------------------------------------------------------- /lib/guard.rb: -------------------------------------------------------------------------------- 1 | # Resat response filter 2 | # Use response filters to validate responses and/or store response elements in 3 | # variables. 4 | # Automatically hydrated with Kwalify from YAML definition. 5 | # See resat.rb for usage information. 6 | # 7 | 8 | module Resat 9 | 10 | class Guard 11 | include Kwalify::Util::HashLike 12 | attr_accessor :failures 13 | 14 | def prepare(variables) 15 | @timeout ||= 120 16 | @period ||= 5 17 | @failures = [] 18 | variables.substitute!(@pattern) 19 | Log.info("Waiting for guard #{@name} with pattern /#{@pattern.to_s}/") 20 | end 21 | 22 | def wait(request) 23 | r = Regexp.new(@pattern) 24 | r.match(request.get_response_field(@field, @target)) 25 | expiration = DateTime.now + @timeout 26 | while !Regexp.last_match && DateTime.now < expiration && request.failures.empty? 27 | sleep @period 28 | request.send 29 | r.match(request.get_response_field(@field, @target)) 30 | end 31 | @failures << "Guard '#{@name}' timed out waiting for field '#{@field}' with pattern '#{@pattern ? @pattern : ''}' from response #{@target}." if !Regexp.last_match 32 | end 33 | end 34 | 35 | end 36 | 37 | -------------------------------------------------------------------------------- /lib/rdoc_patch.rb: -------------------------------------------------------------------------------- 1 | # Patch RDoc so that RDoc::usage works even when the application is started via 2 | # a proxy such as a bash script instead of being run directly. 3 | # See resat.rb for usage information. 4 | # 5 | 6 | require 'rdoc/usage' 7 | 8 | module RDoc 9 | # Force the use of comments in this file so RDoc::usage works even when 10 | # invoked from a proxy (e.g. 'resat' bash script) 11 | def usage_no_exit(*args) 12 | main_program_file = caller[-1].sub(/:\d+$/, '') 13 | usage_from_file(main_program_file) 14 | end 15 | 16 | # Display usage from the given file 17 | def RDoc.usage_from_file(input_file, *args) 18 | comment = File.open(input_file) do |file| 19 | find_comment(file) 20 | end 21 | comment = comment.gsub(/^\s*#/, '') 22 | markup = SM::SimpleMarkup.new 23 | flow_convertor = SM::ToFlow.new 24 | flow = markup.convert(comment, flow_convertor) 25 | format = "plain" 26 | unless args.empty? 27 | flow = extract_sections(flow, args) 28 | end 29 | options = RI::Options.instance 30 | if args = ENV["RI"] 31 | options.parse(args.split) 32 | end 33 | formatter = options.formatter.new(options, "") 34 | formatter.display_flow(flow) 35 | end 36 | end 37 | 38 | -------------------------------------------------------------------------------- /examples/twitter/scenarios/timelines.yml: -------------------------------------------------------------------------------- 1 | # Retrieve public and private recent tweets 2 | # 3 | # http://twitter.com/statuses/public_timeline.format 4 | # http://twitter.com/statuses/public_timeline.format 5 | name: Twitter Timelines 6 | config: ../config/resat.yaml 7 | steps: 8 | - request: 9 | resource: statuses 10 | custom: # Use a custom operation (i.e. not a CRUD operation) 11 | name: public_timeline.xml # Operation name 12 | type: get # GET request 13 | filters: 14 | - name: extract latest public tweet 15 | target: body 16 | extractors: 17 | - field: statuses/status/text 18 | variable: last public tweet 19 | save: yes 20 | - request: 21 | resource: statuses 22 | custom: # Use a custom operation (i.e. not a CRUD operation) 23 | name: friends_timeline.xml # Operation name 24 | type: get # GET request 25 | filters: 26 | - name: extract latest friends tweet 27 | target: body 28 | extractors: 29 | - field: statuses/status/text 30 | variable: last friends tweet 31 | save: yes 32 | -------------------------------------------------------------------------------- /examples/twitter/README.rdoc: -------------------------------------------------------------------------------- 1 | = Synopsis 2 | 3 | This example uses the Twitter REST API: 4 | 5 | http://apiwiki.twitter.com/REST+API+Documentation 6 | 7 | Please note: This example will send a tweet on your behalf with the text: 8 | 9 | Checking out resat (http://tinyurl.com/dg8gf9) 10 | 11 | by default. Override the default text in the config/resat.yaml file or 12 | via the command line: 13 | 14 | $ resat scenarios -d tweet:'My custom tweet' -d user:... -d pass:... 15 | 16 | See the main README.rdoc for instructions on how to setup resat prior to 17 | running the examples. 18 | 19 | = How to 20 | 21 | * Run: 22 | 23 | $ resat scenarios -d user: -d pass: 24 | 25 | * See: 26 | 27 | $ cat output.yml 28 | 29 | * See more: 30 | 31 | $ vi /tmp/resat.log 32 | 33 | or 34 | 35 | $ vi resat.log 36 | 37 | if /tmp does not exist 38 | 39 | = Additional Examples 40 | 41 | The additional folder contains two additional scenarios which are not ran 42 | by default: 43 | 44 | * follow.yml: Follow given user 45 | * send_message.yml: Send direct message to given user with given content 46 | 47 | Both these scenarios require inputs. Inputs are given using the --define 48 | (or -d) resat option: 49 | 50 | $ resat additional/follow -d followed:rgsimon -------------------------------------------------------------------------------- /lib/handler.rb: -------------------------------------------------------------------------------- 1 | # Resat response handler 2 | # Allows defining a handler that gets the request and response objects for custom processing 3 | # Handler should be a module and expose the following methods: 4 | # - :process takes two argumnents: first argument is an instance of Net::HTTPRequest while 5 | # second argument is an instance of Net::HTTPResponse. 6 | # :process should initialize the value returned by :failures (see below) 7 | # - :failures which returns an array of error messages when processing 8 | # the response results in errors or an empty array when the processing 9 | # is successful. 10 | 11 | module Resat 12 | 13 | class Handler 14 | include Kwalify::Util::HashLike 15 | attr_accessor :failures 16 | 17 | def prepare 18 | @failures = [] 19 | Log.info("Running handler '#{@name}'") 20 | end 21 | 22 | def run(request) 23 | klass = module_class(@module) 24 | h = klass.new 25 | h.process(request.request, request.response) 26 | @failures += h.failures.values.to_a if h.failures 27 | end 28 | 29 | protected 30 | 31 | # Create and cache instance of Class which includes 32 | # given module. 33 | def module_class(mod) 34 | @@modules ||= {} 35 | unless klass = @@modules[mod] 36 | klass = Class.new(Object) { include Handler.get_module(mod) } 37 | @@modules[mod] = klass 38 | end 39 | klass 40 | end 41 | 42 | def self.get_module(name) 43 | parts = name.split('::') 44 | index = 0 45 | res = Kernel 46 | index += 1 while (index < parts.size) && (res = res.const_get(parts[index])) 47 | res 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /examples/rightscale/scenarios/create_server.yml: -------------------------------------------------------------------------------- 1 | # Create new Rails all-in-one server in default deployment 2 | name: Create Rails Server 3 | steps: 4 | - request: # Step 1. Retrieve default deployment 5 | operation: index 6 | resource: deployments 7 | valid_codes: 8 | - 200 9 | filters: 10 | - name: Get default deployment 11 | target: body 12 | extractors: 13 | - field: deployments/deployment/href 14 | variable: deployment 15 | 16 | - request: # Step 2. Retrieve Rails template 17 | operation: index 18 | resource: server_templates 19 | filters: 20 | - name: Get Rails server template 21 | target: body 22 | extractors: 23 | - field: server-templates/ec2-server-template[nickname='Rails all-in-one-developer v8']/href 24 | variable: server_template 25 | 26 | - request: # Step 3. Create server 27 | operation: create 28 | resource: servers 29 | valid_codes: 30 | - 201 31 | params: 32 | - name: server[nickname] 33 | value: 'Resat Rails Server' 34 | - name: server[server_template_href] 35 | value: $server_template 36 | - name: server[deployment_href] 37 | value: $deployment 38 | filters: 39 | - name: validate server response 40 | target: body 41 | is_empty: true 42 | - name: Get server id 43 | target: header 44 | extractors: 45 | - field: location 46 | pattern: '.*\/(\d+)$' 47 | variable: server_id 48 | export: true # Export id to other scenarios 49 | -------------------------------------------------------------------------------- /schemas/config.yaml: -------------------------------------------------------------------------------- 1 | # == Synopsis 2 | # 3 | # This file contains the Kwalify YAML schema for the resat config file 4 | # 5 | # The resat config file is used to specify the API URI components as 6 | # well as any required auxiliary information (such as username/password) 7 | # 8 | # The 'params' and 'headers' collections define request parameters and 9 | # headers that will be sent with every API calls by default. 10 | # 11 | # The 'variables' collection define global variables and their values. 12 | # The 'output' field defines the path to the file where extracted 13 | # variables should be saved if the corresponding 'save' field in the 14 | # scenario is true. 15 | # The 'input' field defines the path to the file where variables are 16 | # defined that should be loaded prior to executing any scenario. 17 | # Both the output and input file use YAML to serialize variables so that 18 | # a file produced as an output of resat can be later used as input. 19 | # 20 | # == Note 21 | # 22 | # All the URI components defined in the Config file may be overridden by 23 | # each request in the scenario. 24 | 25 | # Schema for resat configuration 26 | type: map 27 | class: Resat::Config 28 | mapping: 29 | "host": { type: str, required: yes } # Default host 30 | "port": { type: int } # Default port (optional) 31 | "base_url": { type: str } # Default base URL (optional) 32 | "use_ssl": { type: bool, default: no } # http or https? (http by default) 33 | "username": { type: str } # Basic auth username (optional) 34 | "password": { type: str } # Basic auth password (optional) 35 | "params": # Parameters used for all requests 36 | &name_value_pairs 37 | type: seq 38 | sequence: 39 | - type: map 40 | mapping: 41 | "name": { type: str, required: yes, unique: yes } 42 | "value": { type: str, required: yes } 43 | "headers": *name_value_pairs # Headers used for all requests 44 | "delay": { type: str } # Delay in seconds before each request (integer or range) 45 | "variables": *name_value_pairs # Global variables 46 | "output": { type: str } # Path to variables save file 47 | "input": { type: str } # Path to variables load file 48 | 49 | -------------------------------------------------------------------------------- /examples/rightscale/additional/run_server.yml: -------------------------------------------------------------------------------- 1 | # This scenario will launch an All-in-on Rails server, wait for it to become 2 | # operational, run a RightScript (Mongrels restart) and then stop the server, 3 | # wait for it to be stopped and delete it. 4 | # 5 | # Note: This scenario reuses the 'create_server' scenario to create the 6 | # Rails All-in-one server. 7 | # 8 | name: Launch rails servers and run RightScript 9 | includes: 10 | - ../scenarios/create_server 11 | steps: 12 | - request: # Step 1. Start server 13 | resource: servers 14 | id: $server_id 15 | custom: 16 | name: start 17 | type: post 18 | 19 | - request: # Step 2. Retrieve Mongrels restart RightScript 20 | operation: index 21 | resource: right_scripts 22 | filters: 23 | - name: Get Rails server template 24 | target: body 25 | extractors: 26 | - field: right-scripts/right-script[name='RB mongrel_cluster (re)start v1']/href 27 | variable: right_script_id 28 | 29 | - request: # Step 3. Wait for server to become operational 30 | resource: servers 31 | id: $server_id 32 | operation: show 33 | guards: 34 | - name: Wait for operational state 35 | target: body 36 | field: server/state 37 | pattern: "operational" 38 | period: 10 39 | timeout: 900 40 | 41 | - request: # Step 4. Restart Mongrels 42 | resource: servers 43 | id: $server_id 44 | custom: 45 | name: run_script 46 | type: post 47 | params: 48 | - name: right_script 49 | value: $right_script_id 50 | 51 | - request: # Step 5. Stop server 52 | resource: servers 53 | id: $server_id 54 | custom: 55 | name: stop 56 | type: post 57 | 58 | - request: # Step 6. Wait for server to become stopped 59 | resource: servers 60 | id: $server_id 61 | operation: show 62 | guards: 63 | - name: Wait for stop state 64 | target: body 65 | field: server/state 66 | pattern: "stopped" 67 | period: 10 68 | timeout: 900 69 | 70 | - request: # Step 7. Now delete the server 71 | operation: destroy 72 | resource: servers 73 | id: $server_id 74 | 75 | 76 | -------------------------------------------------------------------------------- /lib/variables.rb: -------------------------------------------------------------------------------- 1 | # Resat scenario variables 2 | # Manages variables and provides substitution. 3 | # See resat.rb for usage information. 4 | # 5 | 6 | require 'singleton' 7 | 8 | module Resat 9 | 10 | class Variables 11 | 12 | attr_reader :vars, :marked_for_save, :exported 13 | 14 | # Initialize instance 15 | def initialize 16 | @@exported = Hash.new unless defined? @@exported 17 | @vars = @@exported.dup 18 | @marked_for_save = [] 19 | end 20 | 21 | # Replace occurrences of environment variables in +raw+ with their value 22 | def substitute!(raw) 23 | if raw.kind_of?(String) 24 | scans = Array.new 25 | raw.scan(/[^\$]*\$(\w+)+/) { |scan| scans << scan } 26 | scans.each do |scan| 27 | scan.each do |var| 28 | raw.gsub!('$' + var, @vars[var]) if @vars.include?(var) 29 | end 30 | end 31 | elsif raw.kind_of?(Array) 32 | raw.each { |i| substitute!(i) } 33 | elsif raw.kind_of?(Hash) 34 | raw.each { |k, v| substitute!(v) } 35 | end 36 | end 37 | 38 | def [](key) 39 | vars[key] 40 | end 41 | 42 | def []=(key, value) 43 | vars[key] = value 44 | end 45 | 46 | def include?(key) 47 | vars.include?(key) 48 | end 49 | 50 | def empty? 51 | vars.empty? 52 | end 53 | 54 | def all 55 | vars.sort 56 | end 57 | 58 | # Load variables from given file 59 | def load(file, schemasdir) 60 | schemafile = File.join(schemasdir, 'variables.yaml') 61 | schema = Kwalify::Yaml.load_file(schemafile) 62 | validator = Kwalify::Validator.new(schema) 63 | parser = Kwalify::Yaml::Parser.new(validator) 64 | serialized_vars = parser.parse_file(file) 65 | parser.errors.push(Kwalify::ValidationError.new("No variables defined")) unless serialized_vars 66 | if parser.errors.empty? 67 | serialized_vars.each { |v| vars[v['name']] = v['value'] } 68 | else 69 | Log.warn("Error loading variables from '#{file}': #{KwalifyHelper.parser_error(parser)}") 70 | end 71 | end 72 | 73 | # Save variables to given file 74 | def save(file) 75 | serialized_vars = [] 76 | vars.each do |k, v| 77 | if marked_for_save.include?(k) 78 | serialized_vars << { 'name' => k, 'value' => v } 79 | end 80 | end 81 | File.open(file, 'w') do |out| 82 | YAML.dump(serialized_vars, out) 83 | end 84 | end 85 | 86 | def mark_for_save(key) 87 | @marked_for_save << key 88 | end 89 | 90 | # Exported values will be kept even after new instance is initialized 91 | def export(key) 92 | @@exported[key] = @vars[key] 93 | end 94 | 95 | end 96 | 97 | end 98 | -------------------------------------------------------------------------------- /lib/config.rb: -------------------------------------------------------------------------------- 1 | # Configuration information read from given configuration file 2 | # ('config/resat.yaml' by default). 3 | # 4 | # Configuration example: 5 | # 6 | # # Hostname used for API calls 7 | # host: my.rightscale.com 8 | # 9 | # # Common base URL to all API calls 10 | # base_url: '/api/acct/71/' 11 | # 12 | # # Use HTTPS? 13 | # use_ssl: yes 14 | # 15 | # # Basic auth username if any 16 | # username: raphael@rightscale.com 17 | # 18 | # # Basic auth password if any 19 | # password: Secret 20 | # 21 | # # Common request headers for all API calls 22 | # headers: 23 | # - name: X-API-VERSION 24 | # value: '1.0' 25 | # 26 | # # Common parameters for all API calls 27 | # params: 28 | # 29 | # See resat.rb for usage information. 30 | # 31 | 32 | module Resat 33 | 34 | class Config 35 | 36 | DEFAULT_FILE = 'config/resat.yaml' 37 | 38 | DEFAULT_SCHEMA_DIR = File.expand_path(File.join(File.dirname(__FILE__), '..', 'schemas')) 39 | 40 | DEFAULTS = { 41 | 'base_url' => '', 42 | 'use_ssl' => false, 43 | 'variables' => {} 44 | } 45 | 46 | def initialize(filename, schemasdir) 47 | (self.methods - (Object.instance_methods + [ 'init', 'valid?', 'method_missing' ])).each { |m| self.class.send(:remove_method, m.to_sym) } 48 | schemafile = File.join(schemasdir || DEFAULT_SCHEMA_DIR, 'config.yaml') 49 | unless File.exists?(schemafile) 50 | Log.error("Missing configuration file schema '#{schemafile}'") 51 | @valid = false 52 | return 53 | end 54 | schema = Kwalify::Yaml.load_file(schemafile) 55 | validator = Kwalify::Validator.new(schema) 56 | parser = Kwalify::Yaml::Parser.new(validator) 57 | @valid = true 58 | @config = { 'use_ssl' => false, 'username' => nil, 'password' => nil, 'port' => nil } 59 | cfg_file = filename || DEFAULT_FILE 60 | config = parser.parse_file(cfg_file) 61 | if parser.errors.empty? 62 | if config.nil? 63 | Log.error("Configuration file '#{cfg_file}' is empty.") 64 | @valid = false 65 | else 66 | @config.merge!(config) 67 | # Dynamically define the methods to forward to the config hash 68 | @config.each_key do |meth| 69 | self.class.send(:define_method, meth) do |*args| 70 | @config[meth] || DEFAULTS[meth] 71 | end 72 | end 73 | end 74 | else 75 | errors = parser.errors.inject("") do |msg, e| 76 | msg << "#{e.linenum}:#{e.column} [#{e.path}] #{e.message}\n\n" 77 | end 78 | Log.error("Configuration file '#{cfg_file}' is invalid:\n#{errors}") 79 | @valid = false 80 | end 81 | end 82 | 83 | def valid? 84 | @valid 85 | end 86 | 87 | def method_missing(*args) 88 | nil 89 | end 90 | 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/log.rb: -------------------------------------------------------------------------------- 1 | # Log info, warnings and errors 2 | # See resat.rb for usage information. 3 | # 4 | 5 | require 'logger' 6 | 7 | # Add ability to output colored text to console 8 | # e.g.: puts "Hello".red 9 | class String 10 | def bold; colorize("\e[1m\e[29m"); end 11 | def grey; colorize("\e[30m"); end 12 | def red; colorize("\e[1m\e[31m"); end 13 | def dark_red; colorize("\e[31m"); end 14 | def green; colorize("\e[1m\e[32m"); end 15 | def dark_green; colorize("\e[32m"); end 16 | def yellow; colorize("\e[1m\e[33m"); end 17 | def blue; colorize("\e[1m\e[34m"); end 18 | def dark_blue; colorize("\e[34m"); end 19 | def pur; colorize("\e[1m\e[35m"); end 20 | def colorize(color_code) 21 | # Doesn't work with the Windows prompt... 22 | RUBY_PLATFORM =~ /(win|w)32$/ ? to_s : "#{color_code}#{to_s}\e[0m" 23 | end 24 | end 25 | 26 | module Resat 27 | 28 | class LogFormatter 29 | 30 | def call(severity, time, progname, msg) 31 | msg.gsub!("\n", "\n ") 32 | res = "" 33 | res << "*** " if severity == Logger::ERROR || severity == Logger::FATAL 34 | res << "#{severity} [#{time.strftime('%H:%M:%S')}]: #{msg.to_s}\n" 35 | res 36 | end 37 | end 38 | 39 | class Log 40 | 41 | LEVELS = %w{ debug info warn error fatal } 42 | 43 | # Initialize singleton instance 44 | def Log.init(options) 45 | File.delete(options.logfile) rescue nil 46 | if options.dry_run 47 | options.logfile = STDOUT 48 | else 49 | options.logfile = 'resat.log' unless File.directory?(File.dirname(options.logfile)) 50 | end 51 | @logger = Logger.new(options.logfile) 52 | @logger.formatter = LogFormatter.new 53 | @level = LEVELS.index(options.loglevel.downcase) if options.loglevel 54 | @level = Logger::WARN unless @level # default to warning 55 | @logger.level = @level 56 | @verbose = options.verbose 57 | @quiet = options.quiet 58 | end 59 | 60 | def Log.debug(debug) 61 | @logger.debug { debug } if @logger 62 | puts "\n#{debug}".grey if @level == Logger::DEBUG 63 | end 64 | 65 | def Log.info(info) 66 | puts "\n#{info}".dark_green if @verbose 67 | @logger.info { info } if @logger 68 | end 69 | 70 | def Log.warn(warning) 71 | puts "\nWarning: #{warning}".yellow unless @quiet 72 | @logger.warn { warning } if @logger 73 | end 74 | 75 | def Log.error(error) 76 | puts "\nError: #{error}".dark_red 77 | @logger.error { error } if @logger 78 | end 79 | 80 | def Log.fatal(fatal) 81 | puts "\nCrash: #{fatal}".red 82 | @logger.fatal { fatal } if @logger 83 | end 84 | 85 | def Log.request(request) 86 | msg = "REQUEST #{request.method} #{request.path}" 87 | if request.size > 0 88 | msg << "\nheaders:" 89 | request.each_header do |name, value| 90 | msg << "\n #{name}: #{value}" 91 | end 92 | end 93 | msg << "\nbody: #{request.body}" unless request.body.nil? 94 | Log.info(msg) 95 | end 96 | 97 | def Log.response(response, succeeded = true) 98 | msg = "RESPONSE #{response.code} #{response.message}" 99 | if response.size > 0 100 | msg << "\nheaders:" 101 | response.each_header do |name, value| 102 | msg << "\n #{name}: #{value}" 103 | end 104 | end 105 | msg << "\nbody: #{response.body}" unless response.body.nil? 106 | if succeeded 107 | Log.info(msg) 108 | else 109 | Log.warn(msg) 110 | end 111 | end 112 | 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/engine.rb: -------------------------------------------------------------------------------- 1 | # Resat test engine, reads test files and run them. 2 | # See resat.rb for usage information. 3 | # 4 | 5 | ENG_DIR = File.dirname(__FILE__) 6 | require File.join(ENG_DIR, 'file_set') 7 | require File.join(ENG_DIR, 'log') 8 | require File.join(ENG_DIR, 'scenario_runner') 9 | 10 | module Resat 11 | 12 | class Engine 13 | 14 | attr_accessor :run_count # Total number of run scenarios 15 | attr_accessor :requests_count # Total number of HTTP requests 16 | attr_accessor :ignored_count # Total number of ignored scenarios 17 | attr_accessor :skipped_count # Total number of skipped YAML files 18 | attr_accessor :failures # Hash of error messages (string arrays) 19 | # indexed by scenario filename 20 | attr_accessor :variables # Hash of variables hashes indexed by 21 | # scenario filename 22 | 23 | def initialize(options) 24 | @options = options 25 | @failures = Hash.new 26 | @run_count = 0 27 | @ignored_count = 0 28 | @requests_count = 0 29 | @skipped_count = 0 30 | @variables = Hash.new 31 | end 32 | 33 | # Was test run successful? 34 | def succeeded? 35 | @failures.size == 0 36 | end 37 | 38 | # Run all scenarios and set attributes accordingly 39 | def run(target=nil) 40 | target ||= @options.target 41 | begin 42 | if File.directory?(target) 43 | files = FileSet.new(target, %w{.yml .yaml}) 44 | elsif File.file?(target) 45 | files = [target] 46 | else 47 | @failures[target] ||= [] 48 | @failures[target] << "Invalid taget #{target}: Not a directory, nor a file" 49 | return 50 | end 51 | schemasdir = @options.schemasdir || Config::DEFAULT_SCHEMA_DIR 52 | files.each do |file| 53 | runner = ScenarioRunner.new(file, schemasdir, @options.config, 54 | @options.variables, @options.failonerror, @options.dry_run) 55 | @ignored_count += 1 if runner.ignored? 56 | @skipped_count += 1 unless runner.valid? 57 | if runner.valid? && !runner.ignored? 58 | runner.run 59 | @run_count += 1 60 | @requests_count += runner.requests_count 61 | if runner.succeeded? 62 | @variables[file] = runner.variables 63 | else 64 | @failures[file] = runner.failures 65 | end 66 | else 67 | unless runner.valid? 68 | Log.info "Skipping '#{file}' (#{runner.parser_errors})" 69 | end 70 | Log.info "Ignoring '#{file}'" if runner.ignored? 71 | end 72 | end 73 | rescue Exception => e 74 | Log.error(e.message) 75 | backtrace = " " + e.backtrace.inject("") { |msg, s| msg << "#{s}\n" } 76 | Log.debug(backtrace) 77 | end 78 | end 79 | 80 | def summary 81 | if succeeded? 82 | case run_count 83 | when 0 then res = "\nNo scenario to run." 84 | when 1 then res = "\nOne scenario SUCCEEDED" 85 | else res = "\n#{run_count} scenarios SUCCEEDED" 86 | end 87 | else 88 | i = 1 89 | res = "\nErrors summary:\n" 90 | failures.each do |file, errors| 91 | res << "\n#{i.to_s}) Scenario '#{file}' failed with: " 92 | errors.each do |error| 93 | res << "\n " 94 | res << error 95 | end 96 | i = i + 1 97 | end 98 | res << "\n\n#{i - 1} of #{run_count} scenario#{'s' if run_count > 1} FAILED" 99 | end 100 | res 101 | end 102 | 103 | end 104 | end 105 | 106 | -------------------------------------------------------------------------------- /lib/filter.rb: -------------------------------------------------------------------------------- 1 | # Resat response filter 2 | # Use response filters to validate responses and/or store response elements in 3 | # variables. 4 | # Automatically hydrated with Kwalify from YAML definition. 5 | # See resat.rb for usage information. 6 | # 7 | 8 | module Resat 9 | 10 | class Filter 11 | include Kwalify::Util::HashLike 12 | attr_accessor :failures 13 | 14 | def prepare(variables) 15 | @is_empty ||= false 16 | @failures = [] 17 | @variables = variables 18 | Log.info("Running filter '#{@name}'") 19 | end 20 | 21 | # Run filter on given response 22 | def run(request) 23 | @request = request 24 | @response = request.response 25 | validate 26 | extract 27 | end 28 | 29 | # Validate response 30 | def validate 31 | unless @response 32 | @failures << "No response to validate." 33 | return 34 | end 35 | 36 | # 1. Check emptyness 37 | if @target == 'header' 38 | if @is_empty != (@response.size == 0) 39 | @failures << "Response header #{'not ' if @is_empty}empty." 40 | end 41 | else 42 | if @is_empty != (@response.body.nil? || @response.body.size <= 1) 43 | @failures << "Response body #{'not ' if @is_empty}empty." 44 | end 45 | end 46 | 47 | # 2. Check required fields 48 | @required_fields.each do |field| 49 | unless @request.has_response_field?(field, target) 50 | @failures << "Missing #{target} field '#{field}'." 51 | end 52 | end if @required_fields 53 | 54 | # 3. Evaluate validators 55 | @validators.each do |v| 56 | if @request.has_response_field?(v.field, @target) 57 | field = @request.get_response_field(v.field, @target) 58 | is_ok = v.is_empty && field.empty? 59 | is_ok ||= v.pattern && v.pattern.empty? && field.empty? 60 | if v.pattern && !v.pattern.empty? 61 | @variables.substitute!(v.pattern) 62 | is_ok ||= Regexp.new(v.pattern).match(field) 63 | end 64 | @failures << "Validator /#{v.pattern} failed on '#{field}' from #{@target} field '#{v.field}'." unless is_ok 65 | else 66 | @failures << "Missing #{@target} field '#{v.field}'." 67 | end 68 | end if @validators 69 | end 70 | 71 | # Extract elements from response 72 | def extract 73 | @extractors.each do |ex| 74 | @variables.substitute!(ex.field) 75 | if @request.has_response_field?(ex.field, @target) 76 | field = @request.get_response_field(ex.field, @target) 77 | if ex.pattern 78 | @variables.substitute!(ex.pattern) 79 | Regexp.new(ex.pattern).match(field) 80 | if Regexp.last_match 81 | @variables[ex.variable] = Regexp.last_match(1) 82 | else 83 | Log.warn("Extraction from response #{@target} field '#{ex.field}' ('#{field}') with pattern '#{ex.pattern}' failed.") 84 | end 85 | else 86 | @variables[ex.variable] = field 87 | end 88 | @variables.mark_for_save(ex.variable) if ex.save 89 | @variables.export(ex.variable) if ex.export 90 | else 91 | Log.warn("Extraction from response #{@target} field '#{ex.field}' failed: field not found.") 92 | end 93 | end if @extractors 94 | end 95 | 96 | end 97 | 98 | # Classes for instances hydrated by Kwalify 99 | 100 | class Validator 101 | include Kwalify::Util::HashLike 102 | attr_accessor :field, :is_empty, :pattern 103 | end 104 | 105 | class Extractor 106 | include Kwalify::Util::HashLike 107 | attr_accessor :field, :pattern, :variable 108 | def save; @save || false; end 109 | def export; @export || false; end 110 | end 111 | 112 | end 113 | -------------------------------------------------------------------------------- /lib/api_request.rb: -------------------------------------------------------------------------------- 1 | # API request 2 | # See resat.rb for usage information. 3 | # 4 | 5 | require 'uri' 6 | require 'rexml/document' 7 | require 'json' 8 | require File.join(File.dirname(__FILE__), 'net_patch') 9 | 10 | module Resat 11 | 12 | class ApiRequest 13 | include Kwalify::Util::HashLike 14 | attr_reader :request, :response, :send_count, :failures 15 | 16 | # Prepare request so 'send' can be called 17 | def prepare(variables, config) 18 | @format ||= 'xml' 19 | @failures = [] 20 | @send_count = 0 21 | @config_delay = config.delay 22 | @config_use_ssl = config.use_ssl 23 | 24 | # 1. Normalize call fields 25 | @headers ||= [] 26 | @params ||= [] 27 | # Clone config values so we don't mess with them when expanding variables 28 | config.headers.each do |h| 29 | value = @headers.detect { |header| header['name'] == h['name'] } 30 | @headers << { 'name' => h['name'].dup, 'value' => h['value'].dup } unless value 31 | end if config.headers 32 | config.params.each do |p| 33 | value = @params.detect { |header| header['name'] == h['name'] } 34 | @params << { 'name' => h['name'].dup, 'value' => h['value'].dup } unless value 35 | end if config.params && request_class.REQUEST_HAS_BODY 36 | variables.substitute!(@params) 37 | variables.substitute!(@headers) 38 | 39 | # 2. Build URI 40 | variables.substitute!(@id) if @id 41 | uri_class = (@use_ssl || @use_ssl.nil? && config.use_ssl) ? URI::HTTPS : URI::HTTP 42 | port = @port || config.port || uri_class::DEFAULT_PORT 43 | variables.substitute!(port) 44 | host = @host || config.host 45 | variables.substitute!(host) 46 | @uri = uri_class.build( :host => host, :port => port ) 47 | base_url = "/#{@base_url || config.base_url}/".squeeze('/') 48 | variables.substitute!(base_url) 49 | path = @path 50 | unless path 51 | path = "#{base_url}#{@resource}" 52 | path = "#{path}/#{@id}" if @id 53 | path = "#{path}.#{@format}" if @format && !@format.empty? && !@custom 54 | path = "#{path}#{@custom.separator}#{@custom.name}" if @custom 55 | end 56 | variables.substitute!(path) 57 | @uri.merge!(path) 58 | 59 | # 3. Build request 60 | case @operation 61 | when 'index', 'show' then request_class = Net::HTTP::Get 62 | when 'create' then request_class = Net::HTTP::Post 63 | when 'update' then request_class = Net::HTTP::Put 64 | when 'destroy' then request_class = Net::HTTP::Delete 65 | else 66 | if type = (@type || @custom && @custom.type) 67 | case type 68 | when 'get' then request_class = Net::HTTP::Get 69 | when 'post' then request_class = Net::HTTP::Post 70 | when 'put' then request_class = Net::HTTP::Put 71 | when 'delete' then request_class = Net::HTTP::Delete 72 | end 73 | else 74 | @failures << "Missing request type for request on '#{@resource}'." 75 | return 76 | end 77 | end 78 | @request = request_class.new(@uri.to_s) 79 | username = @username || config.username 80 | variables.substitute!(username) if username 81 | password = @password || config.password 82 | variables.substitute!(password) if password 83 | if username && password 84 | @request.basic_auth(username, password) 85 | end 86 | form_data = Hash.new 87 | @headers.each { |header| @request[header['name']] = header['value'] } 88 | @params.each { |param| form_data[param['name']] = param['value'] } 89 | @request.set_form_data(form_data) unless form_data.empty? 90 | Log.request(@request) 91 | 92 | # 4. Send request and check response code 93 | @oks = @valid_codes.map { |r| r.to_s } if @valid_codes 94 | @oks ||= %w{200 201 202 203 204 205 206} 95 | end 96 | 97 | # Actually send the request 98 | def send 99 | sleep(delay_seconds) # Delay request if needed 100 | http = Net::HTTP.new(@uri.host, @uri.port) 101 | http.use_ssl = @config_use_ssl 102 | begin 103 | res = http.start { |http| @response = http.request(@request) } 104 | rescue Exception => e 105 | @failures << "Exception raised while making request: #{e.message}" 106 | end 107 | if @failures.size == 0 108 | @send_count += 1 109 | if @oks.include?(res.code) 110 | Log.response(@response) 111 | else 112 | Log.response(@response, false) 113 | @failures << "Request returned #{res.code}" 114 | end 115 | end 116 | end 117 | 118 | # Does response include given header or body field? 119 | def has_response_field?(field, target) 120 | !!get_response_field(field, target) 121 | end 122 | 123 | # Get value of response header or body field 124 | def get_response_field(field, target) 125 | return unless @response 126 | return @response[field] if target == 'header' 127 | return @response.body if field.nil? || field.empty? 128 | json = JSON.load(@response.body) rescue nil 129 | res = nil 130 | if json 131 | res = json_field(json, field) 132 | else 133 | doc = REXML::Document.new(@response.body) 134 | elem = doc.elements[field] 135 | res = elem.get_text.to_s if elem 136 | end 137 | res 138 | end 139 | 140 | protected 141 | 142 | # Retrieve JSON body field 143 | def json_field(json, field) 144 | return nil unless json 145 | fields = field.split('/') 146 | fields.each do |field| 147 | if json.is_a?(Array) 148 | json = json[field.to_i] 149 | else 150 | json = json[field] 151 | end 152 | return nil unless json 153 | end 154 | json 155 | end 156 | 157 | # Calculate number of seconds to wait before sending request 158 | def delay_seconds 159 | seconds = nil 160 | if delay = @delay || @config_delay 161 | min_delay = max_delay = nil 162 | if delay =~ /([\d]+)\.\.([\d]+)/ 163 | min_delay = Regexp.last_match[1].to_i 164 | max_delay = Regexp.last_match[2].to_i 165 | elsif delay.to_i.to_s == delay 166 | min_delay = max_delay = delay.to_i 167 | end 168 | if min_delay && max_delay 169 | seconds = rand(max_delay - min_delay + 1) + min_delay 170 | end 171 | end 172 | seconds || 0 173 | end 174 | 175 | end 176 | 177 | end 178 | -------------------------------------------------------------------------------- /lib/scenario_runner.rb: -------------------------------------------------------------------------------- 1 | # Resat test scenario, sequence of api calls and filters. 2 | # See resat.rb for usage information. 3 | # 4 | 5 | require 'kwalify/util/hashlike' 6 | require File.join(File.dirname(__FILE__), 'kwalify_helper') 7 | require File.join(File.dirname(__FILE__), 'config') 8 | require File.join(File.dirname(__FILE__), 'variables') 9 | require File.join(File.dirname(__FILE__), 'api_request') 10 | require File.join(File.dirname(__FILE__), 'guard') 11 | require File.join(File.dirname(__FILE__), 'filter') 12 | require File.join(File.dirname(__FILE__), 'handler') 13 | 14 | module Resat 15 | 16 | class ScenarioRunner 17 | 18 | attr_accessor :requests_count, :parser_errors, :failures, :variables 19 | 20 | # Instantiate new scenario runner with given YAML definition document and 21 | # schemas directory. 22 | # If parsing the scenario YAML definition fails then 'valid?' returns false 23 | # and 'parser_errors' contains the error messages. 24 | def initialize(doc, schemasdir, config, variables, failonerror, dry_run) 25 | @schemasdir = schemasdir 26 | @valid = true 27 | @ignored = false 28 | @name = '' 29 | @failures = Array.new 30 | @requests_count = 0 31 | @failonerror = failonerror 32 | @dry_run = dry_run 33 | parse(doc) 34 | if @valid 35 | @config = Config.new(config || @cfg_file, schemasdir) 36 | @valid = @config.valid? 37 | if @valid 38 | @variables = Variables.new 39 | @variables.load(@config.input, schemasdir) if @config.input && File.readable?(@config.input) 40 | @config.variables.each { |v| @variables[v['name']] = v['value'] } if @config.variables 41 | variables.each { |k, v| @variables[k] = v } if variables 42 | end 43 | end 44 | end 45 | 46 | def ignored?; @ignored; end 47 | def valid?; @valid; end # parser_errors contains the details 48 | def succeeded?; @failures.empty?; end 49 | 50 | # Run the scenario. 51 | # Once scenario has run check 'succeeded?'. 52 | # If 'succeeded?' returns false, use 'failures' to retrieve error messages. 53 | def run 54 | return if @ignored || !@valid 55 | Log.info("-" * 80 + "\nRunning scenario #{@name}") 56 | unless @variables.empty? 57 | info_msg = @variables.all.inject("Using variables:") do |msg, (k, v)| 58 | msg << "\n - #{k}: #{v}" 59 | end 60 | Log.info(info_msg) 61 | end 62 | @steps.each_index do |index| 63 | @current_step = index 64 | @current_file = @steps[index][:origin] 65 | step = @steps[index][:step] 66 | case step 67 | when ApiRequest 68 | @requests_count += @request.send_count if @request # Last request 69 | @request = step 70 | @request.prepare(@variables, @config) 71 | @request.send unless @dry_run 72 | when Guard 73 | step.prepare(@variables) 74 | step.wait(@request) unless @dry_run 75 | when Filter, Handler 76 | step.prepare(@variables) 77 | step.run(@request) unless @dry_run 78 | end 79 | puts step.inspect if step.failures.nil? 80 | step.failures.each { |f| add_failure(f) } 81 | break if @failonerror && !succeeded? # Abort on failure 82 | end 83 | 84 | @requests_count += @request.send_count 85 | @variables.save(@config.output) if @config.output 86 | end 87 | 88 | protected 89 | 90 | # Parse YAML definition file and set 'valid?' and 'parser_errors' 91 | # accordingly 92 | def parse(doc) 93 | parser = KwalifyHelper.new_parser(File.join(@schemasdir, 'scenarios.yaml')) 94 | scenario = parser.parse_file(doc) 95 | if parser.errors.empty? 96 | @ignored = !scenario || scenario.ignore 97 | @cfg_file = File.expand_path(File.join(File.dirname(doc), scenario.config)) if scenario.config 98 | unless @ignored 99 | @name = scenario.name 100 | @steps = Array.new 101 | scenario.includes.each do |inc| 102 | process_include(inc, File.dirname(doc)) 103 | end if scenario.includes 104 | scenario.steps.each do |step| 105 | @steps << { :step => step.request, :origin => doc } 106 | if step.filters 107 | @steps.concat(step.filters.map { |f| { :step => f, :origin => doc } }) 108 | end 109 | if step.handlers 110 | @steps.concat(step.handlers.map { |h| { :step => h, :origin => doc } }) 111 | end 112 | if step.guards 113 | @steps.concat(step.guards.map { |g| { :step => g, :origin => doc } }) 114 | end 115 | end if scenario.steps 116 | end 117 | else 118 | @valid = false 119 | @parser_errors = KwalifyHelper.parser_error(parser) 120 | end 121 | end 122 | 123 | def process_include(inc, dir) 124 | if File.directory?(File.join(dir, inc)) 125 | includes = FileSet.new(File.join(dir, inc), %{.yml .yaml}) 126 | else 127 | path = find_include(inc, dir) 128 | if path 129 | includes = [path] 130 | else 131 | Log.warn("Cannot find include file or directory '#{inc}'") 132 | includes = [] 133 | end 134 | end 135 | includes.each { |i| include_steps(i) } 136 | end 137 | 138 | def include_steps(path) 139 | parser = KwalifyHelper.new_parser(File.join(@schemasdir, 'scenarios.yaml')) 140 | scenario = parser.parse_file(path) 141 | if parser.errors.empty? 142 | scenario.includes.each do |inc| 143 | process_include(inc, File.dirname(path)) 144 | end if scenario.includes 145 | scenario.steps.each do |step| 146 | @steps << { :step => step.request, :origin => path } 147 | if step.filters 148 | @steps.concat(step.filters.map { |f| { :step => f, :origin => path } }) 149 | end 150 | if step.handlers 151 | @steps.concat(step.handlers.map { |h| { :step => h, :origin => path } }) 152 | end 153 | if step.guards 154 | @steps.concat(step.guards.map { |g| { :step => g, :origin => path } }) 155 | end 156 | end 157 | else 158 | Log.error("Cannot include file '#{path}': #{parser.errors.join(", ")}") 159 | end 160 | end 161 | 162 | # Path to include file if it's found, nil otherwise 163 | def find_include(inc, dir) 164 | # File extension is optional in YAML definition 165 | # We'll use the one in the current folder if we can't find it in the same 166 | # folder as the including file 167 | val = File.join(dir, inc + '.yml') 168 | path = val if File.file?(val) 169 | val = File.join(dir, inc + '.yaml') 170 | path = val if File.file?(val) 171 | val = File.join(dir, inc) 172 | path = val if File.file?(val) 173 | return path if path 174 | subs = Dir.entries(dir).select { |f| File.directory?(f) } 175 | subs = subs - FileSet::IGNORED_FOLDERS 176 | subs.detect { |sub| find_include(inc, File.join(dir, sub)) } 177 | end 178 | 179 | 180 | # Append error message to list of failures 181 | def add_failure(failure) 182 | @failures << "Step ##{@current_step} from '#{@current_file}': #{failure}" 183 | end 184 | 185 | end 186 | 187 | # Classes automatically hydrated with Kwalify from YAML definition 188 | 189 | class Scenario 190 | include Kwalify::Util::HashLike # defines [], []= and keys? 191 | attr_accessor :name, :config, :includes, :steps 192 | def ignore; @ignore || false; end 193 | end 194 | 195 | class Step 196 | include Kwalify::Util::HashLike 197 | attr_accessor :request, :filters, :handlers, :guards 198 | end 199 | 200 | class CustomOperation 201 | include Kwalify::Util::HashLike 202 | attr_accessor :name, :type 203 | def separator; @separator || "/"; end 204 | end 205 | 206 | end 207 | -------------------------------------------------------------------------------- /bin/resat: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # === Synopsis 4 | # resat - RightScale API Tester 5 | # 6 | # This application allows making automated REST requests optionally followed 7 | # by validation. It reads scenarios defined in YAML files and executes the 8 | # corresponding steps. A step consist of a REST request followed by any 9 | # number of filters. 10 | # 11 | # Scenarios are defined as YAML documents that must adhere to the Kwalify 12 | # schemas defined in schemas/scenarios.yaml. See the comments in this 13 | # file for additional information. 14 | # 15 | # resat is configured through a YAML configuration file which defines 16 | # information that applies to all requests including the host name, 17 | # base url, whether to use SSL, common headers and body parameters and 18 | # optionally a username and password to be used with basic authentication. 19 | # This configuration file should be located in config/resat.yaml by default. 20 | # 21 | # === Examples 22 | # Run the scenario defined in scenario.yaml: 23 | # resat scenario.yaml 24 | # 25 | # Execute scenarios defined in the 'scenarios' directory and its 26 | # sub-directories: 27 | # resat scenarios 28 | # 29 | # Only execute the scenarios defined in the current directory, do not execute 30 | # scenarios found in sub-directories: 31 | # resat -n . 32 | # 33 | # === Usage 34 | # resat [options] target 35 | # 36 | # For help use: resat -h 37 | # 38 | # === Options 39 | # -h, --help Display help message 40 | # -v, --version Display version, then exit 41 | # -q, --quiet Output as little as possible, override verbose 42 | # -V, --verbose Verbose output 43 | # -n, --norecursion Don't run scenarios defined in sub-directories 44 | # -d, --define NAME:VAL Define global variable (can appear multiple times, 45 | # escape ':' with '::') 46 | # -f, --failonerror Stop resat from continuing to run if an error occurs 47 | # -c, --config PATH Config file path (config/resat.yaml by default) 48 | # -s, --schemasdir DIR Path to schemas directory (schemas/ by default) 49 | # -l, --loglevel LVL Log level: debug, info, warn, error (info by default) 50 | # -F, --logfile PATH Log file path (resat.log by default) 51 | # -D, --dry-run Print requests, don't actually make them 52 | # 53 | 54 | require 'rubygems' 55 | require 'optparse' 56 | require 'ostruct' 57 | require 'date' 58 | require 'benchmark' 59 | THIS_FILE = File.symlink?(__FILE__) ? File.readlink(__FILE__) : __FILE__ 60 | require File.expand_path(File.join(File.dirname(THIS_FILE), '..', 'lib', 'rdoc_patch')) 61 | require File.expand_path(File.join(File.dirname(THIS_FILE), '..', 'lib', 'resat')) 62 | 63 | module Resat 64 | class App 65 | 66 | def initialize(arguments) 67 | @arguments = arguments 68 | 69 | # Set defaults 70 | @options = OpenStruct.new 71 | @options.verbose = false 72 | @options.quiet = false 73 | @options.norecursion = false 74 | @options.failonerror = false 75 | @options.variables = {} 76 | @options.config = nil 77 | @options.schemasdir = File.expand_path(File.join(File.dirname(THIS_FILE), '..', 'schemas')) 78 | @options.loglevel = "info" 79 | @options.logfile = "/tmp/resat.log" 80 | @options.dry_run = false 81 | end 82 | 83 | # Parse options, check arguments, then run tests 84 | def run 85 | if parsed_options? && arguments_valid? 86 | begin 87 | tms = Benchmark.measure { run_tests } 88 | Log.info tms.format("\t\tUser\t\tSystem\t\tReal\nDuration:\t%u\t%y\t%r") 89 | rescue Exception => e 90 | puts "Error: #{e.message}" 91 | end 92 | else 93 | output_usage 94 | @return_value = 1 95 | end 96 | exit @return_value 97 | end 98 | 99 | protected 100 | 101 | def parsed_options? 102 | opts = OptionParser.new 103 | opts.on('-h', '--help') { output_help } 104 | opts.on('-v', '--version') { output_version; exit 0 } 105 | opts.on('-q', '--quiet') { @options.quiet = true } 106 | opts.on('-V', '--verbose') { @options.verbose = true } 107 | opts.on('-n', '--norecursion') { @options.norecursion = true } 108 | opts.on('-f', '--failonerror') { @options.failonerror = true } 109 | opts.on('-d', '--define VAR:VAL') { |v| @options.variables.merge!(var_hash(v)) } 110 | opts.on('-c', '--config PATH') { |cfg| @options.config = cfg } 111 | opts.on('-s', '--schemasdir DIR') { |dir| @options.schemasdir = dir } 112 | opts.on('-l', '--loglevel LEVEL') { |level| @options.loglevel = level } 113 | opts.on('-F', '--logfile LOG') { |log| @options.logfile = log } 114 | opts.on('-D', '--dry-run') { @options.dry_run = true } 115 | 116 | opts.parse!(@arguments) rescue return false 117 | 118 | process_options 119 | true 120 | end 121 | 122 | # Build variable hash from command line option 123 | def var_hash(var) 124 | parts = var.split('::') 125 | key = value = '' 126 | key_built = false 127 | parts.each_index do |idx| 128 | part = parts[idx] 129 | if key_built 130 | value = value + ':' + (part || '') 131 | else 132 | if part.include?(':') 133 | subparts = part.split(':') 134 | part = subparts[0] 135 | value = subparts[1] || '' 136 | key_built = true 137 | end 138 | key = key + ':' if idx > 0 139 | key = key + (part || '') 140 | end 141 | end 142 | { key => value } 143 | end 144 | 145 | # Post-parse processing of options 146 | def process_options 147 | @options.verbose = false if @options.quiet 148 | @options.loglevel.downcase! 149 | @options.target = ARGV[0] unless ARGV.empty? # We'll catch that later 150 | end 151 | 152 | # Check arguments 153 | def arguments_valid? 154 | valid = ARGV.size == 1 155 | if valid 156 | unless %w{ debug info warn error }.include? @options.loglevel 157 | Log.error "Invalid log level '#{@options.loglevel}'" 158 | valid = false 159 | end 160 | unless File.directory?(@options.schemasdir) 161 | Log.error "Non-existent schemas directory '#{@options.schemasdir}'" 162 | valid = false 163 | end 164 | unless File.exists?(ARGV[0]) 165 | Log.error "Non-existent target '#{ARGV[0]}'" 166 | valid = false 167 | end 168 | end 169 | valid 170 | end 171 | 172 | def output_help 173 | output_version 174 | RDoc::usage_from_file(__FILE__) 175 | exit 0 176 | end 177 | 178 | def output_usage 179 | RDoc::usage_from_file(__FILE__, 'usage') 180 | exit 0 181 | end 182 | 183 | def output_version 184 | puts "#{File.basename(__FILE__)} - RightScale Automated API Tester v#{VERSION}\n".blue 185 | end 186 | 187 | def run_tests 188 | Log.init(@options) 189 | opts = "-" * 80 + "\nOptions:" 190 | @options.marshal_dump.each do |name, val| 191 | opts += "\n #{name} = #{val.inspect}" 192 | end 193 | Log.info opts 194 | engine = Engine.new(@options) 195 | engine.run 196 | if engine.succeeded? 197 | puts engine.summary.dark_blue 198 | @return_value = 0 199 | else 200 | puts engine.summary.dark_red 201 | @return_value = 1 202 | end 203 | unless @options.quiet 204 | msg = "" 205 | msg << "#{engine.requests_count} API call#{'s' if engine.requests_count > 1}*" 206 | if engine.ignored_count > 1 207 | msg << "*#{engine.ignored_count} scenario#{'s' if engine.ignored_count > 1} ignored*" 208 | end 209 | if engine.skipped_count > 1 210 | msg << "*#{engine.skipped_count} YAML file#{'s' if engine.skipped_count >1} skipped" 211 | end 212 | msg.gsub!('**', ', ') 213 | msg.delete!('*') 214 | puts msg.dark_blue 215 | end 216 | end 217 | end 218 | end 219 | 220 | # Create and run the app 221 | app = Resat::App.new(ARGV) 222 | app.run 223 | -------------------------------------------------------------------------------- /schemas/scenarios.yaml: -------------------------------------------------------------------------------- 1 | # == Synopsis 2 | # This file contains the Kwalify YAML schema for resat test scenarios. 3 | # Scenarios consists of API requests and responses validation. 4 | # 5 | # A scenario may include other scenarios in which case all the included 6 | # scenarios steps will be executed first even if the included scenario 7 | # is marked as 'ignore' (that way it becomes possible to define 8 | # include-only scenarios). 9 | # 10 | # A request can be defined either as a REST request by listing the REST 11 | # resource and operation or by specifying the path manually. 12 | # 13 | # Each step may define guards, that is conditions that must be validated 14 | # before the flow can proceed. These conditions may apply to the response 15 | # header or body and may define a pattern that a given field must validate 16 | # for the guard to be enabled (and the flow to proceed). If no pattern is 17 | # specified then all that is required is for the call to be successful. 18 | # During execution a guard will re-send the same request until the condition 19 | # gets fulfilled or it timeouts. 20 | # 21 | # API responses bodies and headers can optionally be run through filters. 22 | # Filters can provide additional validation and can also initialize variables 23 | # from the response. These variables can the be used in headers and parameters 24 | # to later API requests as well as patterns used for validation and extraction. 25 | # 26 | # Variables are initialized via extractors defined in filters. Extractors 27 | # may use a regular expression with a capturing group. The variable is 28 | # initialized with the first capturing group match. If no regular expression 29 | # is specified then the whole field is stored in the given variable. 30 | # 31 | # Fields in headers are identified by name while fields in response bodies 32 | # are identified by their XPath e.g.: 33 | # 34 | # deployments/deployment/servers/server/state 35 | # 36 | # Filters validation is done through regular expressions validators and/or 37 | # checking whether a field is empty. All validators regular expressions must 38 | # match for the validation to pass. The validation will be applied to all 39 | # elements matching the field XPath in the case of validators applied to a 40 | # response body. 41 | # 42 | # To use a variable simply prefix its name with the '$' character e.g.: 43 | # 44 | # # Filter definition for extracting 'server_id' variable: 45 | # target: header 46 | # extractors: 47 | # - field: location 48 | # pattern: '.*\/servers\/(\d+)$' 49 | # variable: server_id 50 | # 51 | # # API call definition using the 'server_id' variable: 52 | # operation: show 53 | # resource: server 54 | # id: $server_id 55 | # 56 | # Finally, API responses can also be run through handlers. Handlers have 57 | # access to the raw request and response for processing. The scenario 58 | # specifies the name of the module that implements the handler. The module 59 | # should expose a 'process' method taking two arguments: the underlying 60 | # HTTP request and corresponding response. 61 | 62 | # Schema for scenarios definitions 63 | type: map 64 | class: Resat::Scenario 65 | mapping: 66 | "name": { type: str, required: yes } # Scenario name 67 | "config": { type: str } # Path to config file 68 | "ignore": { type: bool, default: no } # Ignore scenario? 69 | "includes": # Included scenarios if any 70 | type: seq 71 | sequence: 72 | - type: str 73 | "steps": # Scenario steps definition 74 | type: seq 75 | sequence: 76 | - type: map 77 | class: Resat::Step 78 | mapping: 79 | #------------------------------------------------------------------------------------------------------------------------ 80 | "request": # API REQUEST DEFINITION 81 | type: map 82 | class: Resat::ApiRequest 83 | mapping: 84 | "host": { type: str } # Request host (config override) 85 | "port": { type: str } # Request port (config override) 86 | "base_url": { type: str } # Request base URL (config override) 87 | "use_ssl": { type: bool } # http or https (config override) 88 | "username": { type: str } # Basic auth username (config override) 89 | "password": { type: str } # Basic auth password (config override) 90 | "resource": { type: str } # REST request resource 91 | "operation": { enum: [create, index, show, update, destroy] } # REST request operation 92 | "custom": # REST custom operation 93 | type: map 94 | class: Resat::CustomOperation 95 | mapping: 96 | "name": { type: str, required: yes } # REST custom operation name 97 | "separator": { type: str, default: / } # REST separator between base URI and operation name 98 | "type": { enum: [get, post, put, delete], required: yes } # REST custom operation request type 99 | "path": { type: str } # Request path (override REST settings) 100 | "type": { enum: [get, post, put, delete] } # Request type (override REST settings) 101 | "absolute": { type: str } # Absolute 102 | "id": { type: str } # Request resource id 103 | "format": { enum: [js, xml, html, ''], default: xml } # Request format (only applies to GET requests) 104 | "delay": { type: str} # Delay before request is made (integer or range) 105 | "params": # Request body parameters 106 | &name_value_pairs 107 | type: seq 108 | sequence: 109 | - type: map 110 | mapping: 111 | "name": { type: str, required: yes, unique: yes } 112 | "value": { type: str, required: yes } 113 | "headers": *name_value_pairs # Request headers 114 | "valid_codes": # List of valid response codes (all 2xx by default) 115 | type: seq 116 | sequence: 117 | - type: int 118 | #------------------------------------------------------------------------------------------------------------------------ 119 | "guards": # GUARDS DEFINITION 120 | type: seq 121 | sequence: 122 | - type: map 123 | class: Resat::Guard 124 | mapping: 125 | "name": { type: str, required: yes } 126 | "target": { enum: [header, body] } # Guard target (response header or body) 127 | "field": { type: str } # Field guard applies to 128 | "status": { type: int } 129 | "pattern": { type: str } # Pattern field should match if any 130 | "period": { type: int, default: 5 } # Period between API requests 131 | "timeout": { type: int, default: 120 } # Guard timeout 132 | #------------------------------------------------------------------------------------------------------------------------ 133 | "filters": # FILTERS DEFINITION 134 | type: seq 135 | sequence: 136 | - type: map 137 | class: Resat::Filter 138 | mapping: 139 | "name": { type: str, required: yes } # Filter name (used for logging) 140 | "target": { enum: [header, body], required: yes } # Filter target (response header or body) 141 | "is_empty": { type: bool, default: no } # Check target is (not) empty 142 | "required_fields": # List of target's required fields 143 | type: seq 144 | sequence: 145 | - type: str 146 | "validators": # List of validators 147 | type: seq 148 | sequence: 149 | - type: map 150 | class: Resat::Validator 151 | mapping: 152 | "field": { type: str, required: yes } # Field validator applies to 153 | "is_empty": { type: bool, default: no } # Check field is (not) empty 154 | "pattern": { type: str } # Regular expression field must match 155 | "extractors": # List of extractors 156 | type: seq 157 | sequence: 158 | - type: map 159 | class: Resat::Extractor 160 | mapping: 161 | "field": { type: str } # Field extractor applies to, whole body if not specified 162 | "pattern": { type: str, pattern: /.*\(.*\).*/ } # Pattern used to extract data (if any) 163 | "variable": { type: str, required: yes, unique: yes } # Variable extracted data should be stored in 164 | "save": { type: bool, default: no } # Whether variable should be saved in output file 165 | "export": { type: bool, default: no } # Whether variable should be exported so that other 166 | # scenarios in same execution can use it 167 | #------------------------------------------------------------------------------------------------------------------------ 168 | "handlers": # HANDLERS DEFINITION 169 | type: seq 170 | sequence: 171 | - type: map 172 | class: Resat::Handler 173 | mapping: 174 | "name": { type: str, required: yes } # Handler name (used for logging) 175 | "module": { type: str, required: yes } # Handler moduler name 176 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = Resat 2 | 3 | = DESCRIPTION 4 | 5 | == Synopsis 6 | 7 | Resat is a script engine which allows grouping web requests into scenarios. 8 | 9 | A scenario consists of serie of HTTP requests called steps. 10 | 11 | Each step may be associated with guards and/or filters and/or handlers. 12 | 13 | The syntax used to defined scenarios is simple and can be used by programmers and 14 | non-programmers alike. See the WRITING SCENARIOS section below for examples. 15 | 16 | * Guards keep making the same request until the response header and/or body 17 | satisfy(ies) certain conditions. 18 | 19 | * Filters validate the response and may save some of its elements in variables. 20 | Variables can be used to define requests, guards and filters. 21 | 22 | * Handlers allow writing custom code to handle a request and its response. 23 | 24 | Scenarios are defined as YAML documents that must adhere to the Kwalify 25 | schemas defined in schemas/scenarios.yaml. See the comments in this 26 | file for additional information. 27 | 28 | Resat is configured through a YAML configuration file which defines 29 | default values that applies to all requests including the host name, 30 | base url, whether to use SSL, common headers and body parameters and 31 | optionally a username and password to be used with basic authentication. 32 | This configuration file is located in config/resat.yaml by default. 33 | 34 | == Why resat? 35 | 36 | There are two main use cases for resat: 37 | 38 | 1. Scripting: Resat can be used to chaing together a serie of REST API calls 39 | that can be used to perform repetitive tasks. 40 | 41 | 2. API testing: For REST API implementors, resat is the ideal automated 42 | regression tool. This is the tool we use at RightScale to test our APIs. 43 | 44 | == How to use 45 | 46 | resat can be used as a ruby library or as an application. Using it as library 47 | involves instantiating the engine and calling the 'run' method: 48 | 49 | require 'resat' 50 | 51 | options = OpenStruct.new 52 | options.verbose = false 53 | options.quiet = false 54 | options.norecursion = false 55 | options.loglevel = 'info' 56 | options.logfile = 'resat.log' 57 | options.configfile = 'config/resat.yaml' 58 | options.schemasdir = 'schemas' 59 | 60 | Resat::Log.init(options) 61 | engine = Resat::Engine.new(options) 62 | engine.run('my_scenario.yaml') 63 | 64 | if engine.succeeded? 65 | puts engine.summary.dark_blue 66 | else 67 | puts engine.summary.dark_red 68 | end 69 | puts "#{engine.requests_count} request(s)." 70 | puts "#{engine.ignored_count} scenario(s) ignored." 71 | puts "#{engine.skipped_count} YAML file(s) skipped." 72 | 73 | See the examples and usage sections below for using resat as an application. 74 | 75 | == Examples 76 | 77 | Run the scenario defined in scenario.yaml: 78 | 79 | $ resat scenario.yaml 80 | 81 | Execute scenarios defined in the 'scenarios' directory and its 82 | sub-directories: 83 | 84 | $ resat scenarios 85 | 86 | Only execute the scenarios defined in the current directory, do not execute 87 | scenarios found in sub-directories: 88 | 89 | $ resat -n . 90 | 91 | == Usage 92 | 93 | resat [options] target 94 | 95 | For help use: resat -h 96 | 97 | == Options 98 | 99 | -h, --help Display help message 100 | -v, --version Display version, then exit 101 | -q, --quiet Output as little as possible, override verbose 102 | -V, --verbose Verbose output 103 | -n, --norecursion Don't run scenarios defined in sub-directories 104 | -d, --define NAME:VAL Define global variable (can appear multiple times, 105 | escape ':' with '::') 106 | -f, --failonerror Stop resat from continuing to run if an error occurs 107 | -c, --config PATH Config file path (config/resat.yaml by default) 108 | -s, --schemasdir DIR Path to schemas directory (schemas/ by default) 109 | -l, --loglevel LVL Log level: debug, info, warn, error (info by default) 110 | -F, --logfile PATH Log file path (resat.log by default) 111 | 112 | = INSTALLATION 113 | 114 | * From source: run the following command from the root folder to be able to run resat from anywhere: 115 | 116 | $ sudo ln -s `pwd`/bin/resat /usr/local/bin/resat 117 | 118 | * Using the gem: 119 | 120 | $ sudo gem install resat 121 | 122 | = DEVELOPMENT 123 | 124 | == Source 125 | 126 | The source code of Resat is available via Git: http://github.com/raphael/resat.git 127 | Fork the project and send pull requests to contribute! 128 | 129 | == Dependencies 130 | 131 | resat relies on Kwalify for validating YAML files: 132 | 133 | $ sudo gem install kwalify 134 | 135 | * http://www.kuwata-lab.com/kwalify/ 136 | 137 | = WRITING SCENARIOS 138 | 139 | At the heart of your resat scripts are the scenarios. A scenario consists of 140 | one or more steps. A scenario may include other scenarios. A single execution 141 | of Resat can apply to multiple scenarios (all scenarios in a given folder). 142 | 143 | A simple scenario containing a single step is defined below: 144 | 145 | name: List all servers 146 | steps: 147 | - request: 148 | operation: index 149 | resource: servers 150 | 151 | The first element of the scenario is its name. The name is used by the command 152 | line tool for update and error outputs. 153 | 154 | The second element is the list of steps. A step must contain a request. A 155 | request can correspond to one of the REST CRUD operations and apply to a 156 | resource. CRUD operations are create, show, index, update, 157 | and destroy. 158 | 159 | Operations that apply to a single resource rather than to all resources require 160 | the id element: 161 | 162 | name: Show server 42 163 | steps: 164 | - request: 165 | operation: show 166 | resource: servers 167 | id: 42 168 | 169 | Resat also allows defining custom REST operations for making web requests that 170 | don't map to a standard CRUD operation. A custom operation is defined by a type 171 | corresponding to the HTTP verb that the request should use (i.e. get, post, 172 | put or delete) and its name. 173 | 174 | name: Twitter Timelines 175 | steps: 176 | - request: 177 | resource: statuses 178 | custom: # Use a custom operation 179 | name: public_timeline.xml # Operation name 180 | type: get # GET request 181 | 182 | Alternatively, the path of a request can be defined manually: 183 | 184 | name: Twitter Timeline 185 | steps: 186 | - request: 187 | path: statuses/public_timeline.xml 188 | type: get 189 | 190 | Requests can then be followed by filters which can validate the response and/or 191 | extract elements from it. 192 | 193 | name: Get Mephisto ServerTemplate 194 | steps: 195 | - request: 196 | operation: index 197 | resource: server_templates 198 | filters: 199 | - name: get server template href 200 | target: body 201 | validators: 202 | - field: server-templates/ec2-server-template[nickname='Mephisto all-in-one v8']/href 203 | is_empty: false 204 | extractors: 205 | - field: server-templates/ec2-server-template[nickname='Mephisto all-in-one v8']/href 206 | variable: server_template_href 207 | 208 | Variables that are extracted from a request response can then be used for 209 | other requests, filters or guards. A variable is used using the $ sign 210 | followed by the variable name. A variable may be written to an output file if 211 | it has the save element and the configuration file defines an output 212 | file. A variable can also be exported to other scenarios that will get run in 213 | the same Resat execution (so a scenario can create resources and save their ids 214 | and a following scenario can reuse the ids to delete or update the resources). 215 | 216 | The element to extract can be a response header or a response body field. If it 217 | is a response body field then an XPATH query is used to identity which part of 218 | the response body should be extracted. 219 | 220 | The value to be extracted can be further defined using a regular expression 221 | with a capture block. The regular expression is applied to the field matching 222 | the XPATH associated with the extractor. 223 | 224 | Note: Because XPATH is used to define fields in extractors and 225 | validators, only requests that return XML can be followed by filters. 226 | 227 | name: Create Mephisto Server 228 | steps: 229 | - request: 230 | operation: create 231 | resource: servers 232 | valid_codes: 233 | - 201 234 | params: 235 | - name: server[nickname] 236 | value: 'resat created server' 237 | - name: server[server_template_href] 238 | value: $server_template_href 239 | - name: server[deployment_href] 240 | value: $deployment_href 241 | filters: 242 | - name: validate server response 243 | target: body 244 | is_empty: true 245 | - name: extract server id 246 | target: header 247 | extractors: 248 | - field: location 249 | pattern: '.*\/(\d+)$' 250 | variable: server_id 251 | 252 | A scenario request can also use guards. A guard identifies a response 253 | element similarly to an extractor (response header or body field identified by 254 | an XPATH and optionally a regular expression). A guard specifies a value that 255 | the element must match together with a period and a timeout that should be used 256 | to retry the request until the value matches the guard or the timeout is 257 | reached. 258 | 259 | name: Wait until server 42 is operational 260 | steps: 261 | - request: 262 | resource: servers 263 | id: 42 264 | operation: show 265 | guards: 266 | - target: body 267 | field: server/state 268 | pattern: 'operational' 269 | period: 10 270 | timeout: 300 271 | name: server operational 272 | 273 | Finally a scenario request can include handlers. Handlers can only be 274 | included when resat is used as a library. The handler definition lists a unique 275 | name followed the corresponding ruby module name. 276 | 277 | name: Store servers definitions 278 | steps: 279 | - request: 280 | resource: servers 281 | operation: index 282 | handlers: 283 | - name: save results 284 | module: ServersPersister 285 | 286 | The ruby module must define a process method which accepts two arguments: 287 | 288 | def process(request, response) 289 | 290 | * request: an instance of Net::HTTPRequest corresponding to the request associated with this handler. 291 | * response: an instance of Net::HTTPResponse which contains the associated response. 292 | 293 | It should also define a failures method which can return a list of errors. 294 | The errors will get logged and optionally stop the execution of resat if the 295 | failonerror option is set to true. 296 | 297 | = ADDITIONAL RESOURCES 298 | 299 | * Refer to the examples (http://github.com/raphael/resat/tree/master/examples) 300 | for fully functional and documented scenarios. 301 | * See the file schemas/scenarios.yaml 302 | (http://github.com/raphael/resat/blob/master/schemas/scenarios.yaml) for 303 | the complete reference on scenarios syntax. 304 | 305 | = LICENSE 306 | 307 | Resat - Web scripting for the masses 308 | 309 | Author:: Raphael Simon () 310 | Copyright:: Copyright (c) 2009 RightScale, Inc. 311 | 312 | Permission is hereby granted, free of charge, to any person obtaining 313 | a copy of this software and associated documentation files (the 314 | 'Software'), to deal in the Software without restriction, including 315 | without limitation the rights to use, copy, modify, merge, publish, 316 | distribute, sublicense, and/or sell copies of the Software, and to 317 | permit persons to whom the Software is furnished to do so, subject to 318 | the following conditions: 319 | 320 | The above copyright notice and this permission notice shall be 321 | included in all copies or substantial portions of the Software. 322 | 323 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 324 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 325 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 326 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 327 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 328 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 329 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 330 | --------------------------------------------------------------------------------