apify 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +25 -0
- data/Rakefile +40 -0
- data/VERSION +1 -0
- data/apify.gemspec +103 -0
- data/app/helpers/apify_helper.rb +14 -0
- data/app/views/apify/api/_actions.html.erb +38 -0
- data/app/views/apify/api/_client.html.erb +38 -0
- data/app/views/apify/api/_overview.html.erb +9 -0
- data/app/views/apify/api/_protocol.html.erb +38 -0
- data/app/views/apify/api/docs.html.erb +66 -0
- data/lib/apify.rb +7 -0
- data/lib/apify/action.rb +98 -0
- data/lib/apify/api.rb +65 -0
- data/lib/apify/api_controller.rb +99 -0
- data/lib/apify/client.rb +52 -0
- data/lib/apify/errors.rb +12 -0
- data/lib/apify/exchange.rb +53 -0
- data/lib/apify/schema_helper.rb +56 -0
- data/spec/apify/action_spec.rb +35 -0
- data/spec/apify/client_spec.rb +24 -0
- data/spec/app_root/app/controllers/api_controller.rb +8 -0
- data/spec/app_root/app/controllers/application_controller.rb +3 -0
- data/spec/app_root/app/models/api.rb +42 -0
- data/spec/app_root/config/boot.rb +114 -0
- data/spec/app_root/config/database.yml +21 -0
- data/spec/app_root/config/environment.rb +14 -0
- data/spec/app_root/config/environments/in_memory.rb +0 -0
- data/spec/app_root/config/environments/mysql.rb +0 -0
- data/spec/app_root/config/environments/postgresql.rb +0 -0
- data/spec/app_root/config/environments/sqlite.rb +0 -0
- data/spec/app_root/config/environments/sqlite3.rb +0 -0
- data/spec/app_root/config/routes.rb +12 -0
- data/spec/app_root/lib/console_with_fixtures.rb +4 -0
- data/spec/app_root/script/console +7 -0
- data/spec/controllers/api_controller_spec.rb +155 -0
- data/spec/rcov.opts +2 -0
- data/spec/spec.opts +4 -0
- data/spec/spec_helper.rb +31 -0
- metadata +151 -0
data/lib/apify/api.rb
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
module Apify
|
2
|
+
class Api
|
3
|
+
class << self
|
4
|
+
|
5
|
+
def action(method, name, &block)
|
6
|
+
method = method.to_sym
|
7
|
+
name = name.to_sym
|
8
|
+
if block
|
9
|
+
action = Apify::Action.new(method, name, &block)
|
10
|
+
indexed_actions[name][method] = action
|
11
|
+
actions << action
|
12
|
+
else
|
13
|
+
indexed_actions[name][method] or raise "Unknown API action: #{name}"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def get(name, &block)
|
18
|
+
action(:get, name, &block)
|
19
|
+
end
|
20
|
+
|
21
|
+
def post(name, &block)
|
22
|
+
action(:post, name, &block)
|
23
|
+
end
|
24
|
+
|
25
|
+
def put(name, &block)
|
26
|
+
action(:put, name, &block)
|
27
|
+
end
|
28
|
+
|
29
|
+
def delete(name, &block)
|
30
|
+
action(:delete, name, &block)
|
31
|
+
end
|
32
|
+
|
33
|
+
def actions
|
34
|
+
@actions ||= []
|
35
|
+
end
|
36
|
+
|
37
|
+
def indexed_actions
|
38
|
+
@indexed_actions ||= Hash.new { |hash, k| hash[k] = {} }
|
39
|
+
end
|
40
|
+
|
41
|
+
def draw_routes(map, options = {})
|
42
|
+
options[:base_path] ||= 'api'
|
43
|
+
options[:controller] ||= 'api'
|
44
|
+
indexed_actions.each do |name, methods|
|
45
|
+
methods.each do |method, action|
|
46
|
+
connect_route(map, name, method, options)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
connect_route(map, 'docs', :get, options)
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def connect_route(map, name, method, options)
|
55
|
+
options = options.dup
|
56
|
+
base_path = options.delete :base_path
|
57
|
+
map.connect(
|
58
|
+
base_path ? "#{base_path}/#{name}" : name,
|
59
|
+
options.merge(:action => name.to_s, :conditions => { :method => method })
|
60
|
+
)
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
module Apify
|
2
|
+
class ApiController < ActionController::Base
|
3
|
+
|
4
|
+
helper ApifyHelper
|
5
|
+
|
6
|
+
class << self
|
7
|
+
|
8
|
+
def authenticate(options)
|
9
|
+
@user = options[:user] or raise "Missing user for authentication"
|
10
|
+
@password = options[:password] or raise "Missing password for authentication"
|
11
|
+
@authenticate_condition = options[:if] || lambda { true }
|
12
|
+
end
|
13
|
+
|
14
|
+
def api(api)
|
15
|
+
@api = api
|
16
|
+
@api.indexed_actions.each do |name, methods|
|
17
|
+
define_method name do
|
18
|
+
if action = methods[request.method]
|
19
|
+
if params[:schema].present?
|
20
|
+
render_schema(params[:schema], action)
|
21
|
+
elsif params[:example].present?
|
22
|
+
render_example(params[:example], action)
|
23
|
+
else
|
24
|
+
respond_with_action(action)
|
25
|
+
end
|
26
|
+
else
|
27
|
+
render_method_not_allowed(request.method)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
@api
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
|
36
|
+
skip_before_filter :verify_authenticity_token
|
37
|
+
|
38
|
+
before_filter :require_api_authentication
|
39
|
+
|
40
|
+
def docs
|
41
|
+
render 'apify/api/docs', :layout => false
|
42
|
+
end
|
43
|
+
|
44
|
+
def api
|
45
|
+
configuration(:api) || self.class.api(::Api)
|
46
|
+
end
|
47
|
+
|
48
|
+
def authentication_configured?
|
49
|
+
!!@authenticate_condition
|
50
|
+
end
|
51
|
+
|
52
|
+
helper_method :api, :authentication_configured?
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def configuration(symb)
|
57
|
+
self.class.instance_variable_get("@#{symb}")
|
58
|
+
end
|
59
|
+
|
60
|
+
def respond_with_action(action)
|
61
|
+
args = params[:args].present? ? JSON.parse(params[:args]) : {}
|
62
|
+
exchange = action.respond(args)
|
63
|
+
render_api_response(exchange, "#{action.name}.json")
|
64
|
+
end
|
65
|
+
|
66
|
+
def render_api_response(exchange, filename)
|
67
|
+
# p exchange.value
|
68
|
+
if exchange.successful?
|
69
|
+
send_data exchange.value.to_json, :status => '200', :type => 'application/json', :filename => filename
|
70
|
+
else
|
71
|
+
send_data exchange.value, :status => '500', :type => 'text/plain', :filename => filename
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def render_method_not_allowed(method)
|
76
|
+
send_data "Method not allowed: #{method}", :status => 405, :type => 'text/plain'
|
77
|
+
end
|
78
|
+
|
79
|
+
def render_schema(nature, action)
|
80
|
+
send_data JSON.pretty_generate(action.schema(nature)), :status => 200, :type => "application/schema+json", :filename => "#{action.name}.schema.json"
|
81
|
+
end
|
82
|
+
|
83
|
+
def render_example(nature, action)
|
84
|
+
send_data JSON.pretty_generate(action.example(nature)), :status => 200, :type => "application/json", :filename => "#{action.name}.example.json"
|
85
|
+
end
|
86
|
+
|
87
|
+
def require_api_authentication
|
88
|
+
condition = configuration(:authenticate_condition)
|
89
|
+
if condition && instance_eval(&condition)
|
90
|
+
authenticate_or_request_with_http_basic do |user, password|
|
91
|
+
required_user = configuration(:user)
|
92
|
+
required_password = configuration(:password)
|
93
|
+
required_user.present? && required_password.present? && user.strip == required_user && password.strip == required_password
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|
99
|
+
end
|
data/lib/apify/client.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
module Apify
|
2
|
+
class Client
|
3
|
+
|
4
|
+
def initialize(options)
|
5
|
+
@host = options[:host] or raise "Missing :host parameter"
|
6
|
+
@port = options[:port]
|
7
|
+
@protocol = options[:protocol] || 'http'
|
8
|
+
@user = options[:user]
|
9
|
+
@password = options[:password]
|
10
|
+
end
|
11
|
+
|
12
|
+
def get(*args)
|
13
|
+
request(:get, *args)
|
14
|
+
end
|
15
|
+
|
16
|
+
def post(*args)
|
17
|
+
request(:post, *args)
|
18
|
+
end
|
19
|
+
|
20
|
+
def put(*args)
|
21
|
+
request(:put, *args)
|
22
|
+
end
|
23
|
+
|
24
|
+
def delete(*args)
|
25
|
+
request(:delete, *args)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def request(method, path, args = nil)
|
31
|
+
url = build_url(path)
|
32
|
+
args ||= {}
|
33
|
+
json = RestClient.send(method, url, :args => args.to_json)
|
34
|
+
JSON.parse(json)
|
35
|
+
rescue RestClient::Unauthorized => e
|
36
|
+
raise Apify::RequestFailed.new("Unauthorized")
|
37
|
+
rescue RestClient::ExceptionWithResponse => e
|
38
|
+
raise Apify::RequestFailed.new("API request failed with status #{e.http_code}", e.http_body)
|
39
|
+
end
|
40
|
+
|
41
|
+
def build_url(path)
|
42
|
+
url = ""
|
43
|
+
url << @protocol
|
44
|
+
url << '://'
|
45
|
+
url << "#{@user}:#{@password}@" if @user
|
46
|
+
url << @host
|
47
|
+
url << ":#{@port}" if @port
|
48
|
+
url << path
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
end
|
data/lib/apify/errors.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
|
2
|
+
module Apify
|
3
|
+
class Exchange
|
4
|
+
|
5
|
+
attr_reader :args, :value
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@value = nil
|
9
|
+
@error = false
|
10
|
+
@args = nil
|
11
|
+
end
|
12
|
+
|
13
|
+
def successful?
|
14
|
+
not @error
|
15
|
+
end
|
16
|
+
|
17
|
+
def respond(args, action)
|
18
|
+
logging_errors do
|
19
|
+
@args = args
|
20
|
+
validate(@args, action.schema(:args), 'Invalid request args')
|
21
|
+
@value = instance_eval(&action.responder) || {}
|
22
|
+
validate(@value, action.schema(:value), 'Invalid response value')
|
23
|
+
end
|
24
|
+
successful?
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
attr_writer :value
|
30
|
+
|
31
|
+
def validate(object, schema, message_prefix)
|
32
|
+
JSON::Schema.validate(object, schema) if schema
|
33
|
+
rescue JSON::Schema::ValueError => e
|
34
|
+
raise "#{message_prefix}: #{e.message}"
|
35
|
+
end
|
36
|
+
|
37
|
+
def logging_errors(&block)
|
38
|
+
block.call
|
39
|
+
rescue Exception => e
|
40
|
+
@error = true
|
41
|
+
@value = e.message
|
42
|
+
end
|
43
|
+
|
44
|
+
def sql_datetime(time)
|
45
|
+
time.present? ? time.strftime("%Y-%m-%d %H:%M:%S") : nil
|
46
|
+
end
|
47
|
+
|
48
|
+
def sql_date(date)
|
49
|
+
date.present? ? date.strftime("%Y-%m-%d") : nil
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Apify
|
2
|
+
module SchemaHelper
|
3
|
+
|
4
|
+
def optional(schema)
|
5
|
+
schema.merge('optional' => true)
|
6
|
+
end
|
7
|
+
|
8
|
+
def object(properties = nil)
|
9
|
+
{ 'type' => 'object' }.tap do |schema|
|
10
|
+
schema['properties'] = properties if properties
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def array(items = nil)
|
15
|
+
{ 'type' => 'array' }.tap do |schema|
|
16
|
+
schema['items'] = items if items
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def string
|
21
|
+
{ 'type' => 'string' }
|
22
|
+
end
|
23
|
+
|
24
|
+
def boolean
|
25
|
+
{ 'type' => 'boolean' }
|
26
|
+
end
|
27
|
+
|
28
|
+
def enum(schema, allowed_values)
|
29
|
+
schema.merge('enum' => allowed_values)
|
30
|
+
end
|
31
|
+
|
32
|
+
def number
|
33
|
+
{ 'type' => 'number' }
|
34
|
+
end
|
35
|
+
|
36
|
+
def integer
|
37
|
+
{ 'type' => 'integer' }
|
38
|
+
end
|
39
|
+
|
40
|
+
def sql_date
|
41
|
+
{ 'type' => 'string',
|
42
|
+
'pattern' => Patterns::SQL_DATE.source }
|
43
|
+
end
|
44
|
+
|
45
|
+
def sql_datetime
|
46
|
+
{ 'type' => 'string',
|
47
|
+
'pattern' => Patterns::SQL_DATETIME.source }
|
48
|
+
end
|
49
|
+
|
50
|
+
def email
|
51
|
+
{ 'type' => 'string',
|
52
|
+
'pattern' => Patterns::EMAIL.source }
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Apify::Action do
|
4
|
+
|
5
|
+
describe '#uid' do
|
6
|
+
|
7
|
+
it "should return a string uniquely describing the action within an API" do
|
8
|
+
action = Apify::Action.new(:post, :hello_world) {}
|
9
|
+
action.uid.should == 'post_hello_world'
|
10
|
+
end
|
11
|
+
|
12
|
+
end
|
13
|
+
|
14
|
+
describe '#example' do
|
15
|
+
|
16
|
+
it "should return an example for a JSON object matching the schema" do
|
17
|
+
action = Apify::Action.new(:post, :hello_world) do
|
18
|
+
schema :value do
|
19
|
+
object(
|
20
|
+
'string_property' => string,
|
21
|
+
'integer_property' => integer
|
22
|
+
)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
action.example(:value).should == {
|
27
|
+
'string_property' => 'string',
|
28
|
+
'integer_property' => 123
|
29
|
+
}
|
30
|
+
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Apify::Client do
|
4
|
+
|
5
|
+
it "raise an exception if authorization is missing" do
|
6
|
+
client = Apify::Client.new(:host => 'host')
|
7
|
+
stub_http_request(:get, 'http://host/api').to_return(:status => 401, :body => 'the body')
|
8
|
+
expect { client.get '/api' }.to raise_error(Apify::RequestFailed)
|
9
|
+
end
|
10
|
+
|
11
|
+
it "raise an exception if there is a server error" do
|
12
|
+
client = Apify::Client.new(:host => 'host')
|
13
|
+
stub_http_request(:get, 'http://host/api').to_return(:status => 500, :body => 'the body')
|
14
|
+
expect { client.get '/api' }.to raise_error(Apify::RequestFailed)
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should return the parsed JSON object if the request went through" do
|
18
|
+
client = Apify::Client.new(:host => 'host')
|
19
|
+
stub_http_request(:get, 'http://host/api').to_return(:status => 200, :body => '{ "key": "value" }')
|
20
|
+
client.get('/api').should == { 'key' => 'value' }
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
|
@@ -0,0 +1,42 @@
|
|
1
|
+
class Api < Apify::Api
|
2
|
+
|
3
|
+
post :ping do
|
4
|
+
respond do
|
5
|
+
{ 'message' => 'pong' }
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
post :fail do
|
10
|
+
respond do
|
11
|
+
raise "error message"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
post :echo_args do
|
16
|
+
respond do
|
17
|
+
args
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
post :with_args_schema do
|
22
|
+
schema :args do
|
23
|
+
{ "type" => "object",
|
24
|
+
"properties" => {
|
25
|
+
"string_arg" => string
|
26
|
+
}}
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
post :with_value_schema do
|
31
|
+
schema :value do
|
32
|
+
{ "type" => "object",
|
33
|
+
"properties" => {
|
34
|
+
"string_value" => string
|
35
|
+
}}
|
36
|
+
end
|
37
|
+
respond do
|
38
|
+
args
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|