shanesveller-webbynode-api 0.0.5 → 0.0.6

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc CHANGED
@@ -4,13 +4,19 @@ This gem wraps the API available for the VPS hosting company
4
4
  {WebbyNode}[http://www.webbynode.com]. Details and a preliminary
5
5
  design document for the API itself are available
6
6
  {here}[http://howto.webbynode.com/topic.php?id=25]. Functionality
7
- is currently based on the API guide version 1.
7
+ is currently based on the API guide version 2.
8
+
9
+ == Notice
10
+ All previous methods/initializers used named/ordered arguments. New API
11
+ interactions are designed to use hashes of arguments instead. Older API
12
+ models will be converted to this new design pattern soon.
8
13
 
9
14
  == Currently Supported API functionality
10
15
  * Client information such as account status, contact/address info, credit
11
16
  * Webby information (status) and simple actions (start, shutdown, reboot)
12
17
  * List all webbies
13
18
  * DNS zone information such as domain name, TTL, and status
19
+ * Creation/deletion of DNS zones
14
20
 
15
21
  == In Development
16
22
  * DNS record information such as type, data, name, id, and TTL
@@ -52,6 +58,13 @@ is currently based on the API guide version 1.
52
58
  pp @dns.domain
53
59
  pp @dns.records
54
60
 
61
+ === DNS Zone Creation/Deletion
62
+ require 'webbynode-api'
63
+ email = "example@email.com"
64
+ api_key = "1234567890abcdef"
65
+ @new_zone = WebbyNode::DNS.new_zone(:email => email, :token => token, :domain => "mynewdomain.com.", :ttl => 86400)
66
+ pp @new_zone["id"]
67
+
55
68
  == Copyright
56
69
 
57
70
  Copyright (c) 2009 Shane Sveller. See LICENSE for details.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.5
1
+ 0.0.6
data/lib/webbynode-api.rb CHANGED
@@ -18,7 +18,7 @@ class WebbyNode
18
18
 
19
19
  # Uses HTTParty to submit a secure API request via email address and token
20
20
  def auth_get(url, options = {})
21
- raise ArgumentError, "No API information given" unless @email && @api_key
21
+ raise ArgumentError, "API information is missing or incomplete" unless @email && @api_key
22
22
  options[:query] ||= {}
23
23
  options[:query].merge!(:email => @email, :token => @api_key)
24
24
  results = self.class.get(url, options)
@@ -26,6 +26,18 @@ class WebbyNode
26
26
  return results
27
27
  end
28
28
 
29
+ def auth_post(url, options = {})
30
+ self.class.auth_post(url, options)
31
+ end
32
+
33
+ def self.auth_post(url, options = {})
34
+ options[:query] ||= {}
35
+ raise ArgumentError, "API information is missing or incomplete" unless options[:query][:email] && options[:query][:token]
36
+ results = self.post(url, options)
37
+ raise ArgumentError, "Probable bad API information given" if results == {}
38
+ return results
39
+ end
40
+
29
41
  # Catches simple requests for specific API data returned via a hash
30
42
  def method_missing(method)
31
43
  key = @data[method.to_s] if @data
@@ -34,4 +46,4 @@ class WebbyNode
34
46
  end
35
47
  end
36
48
 
37
- require File.join(File.dirname(__FILE__), 'webbynode-api', 'data')
49
+ require File.join(File.dirname(__FILE__), 'webbynode-api', 'data')
@@ -52,8 +52,31 @@ class WebbyNode
52
52
  end
53
53
 
54
54
  def records
55
- raise "This method should only be called on DNS instances with an id." unless @id
55
+ raise "This method should only be called on DNS instances with an id" unless @id
56
56
  auth_get("/api/xml/dns/#{@id}/records")["hash"]["records"]
57
57
  end
58
+
59
+ def self.new_zone(options = {})
60
+ raise ArgumentError, "API access information via :email and :token are required arguments" unless options[:email] && options[:token]
61
+ raise ArgumentError, ":domain and :ttl are required arguments" unless options[:domain] && options[:ttl]
62
+ if options[:status]
63
+ raise ArgumentError, ":status must be 'Active' or 'Inactive'" unless %w(Active Inactive).include? options[:status]
64
+ end
65
+ zone_data = auth_post("/api/xml/dns/new", :query =>
66
+ {
67
+ :email => options[:email],
68
+ :token => options[:token],
69
+ "zone[domain]" => options[:domain],
70
+ "zone[ttl]" => options[:ttl],
71
+ "zone[status]" => options[:status]
72
+ })
73
+ return zone_data["hash"]
74
+ end
75
+
76
+ def self.delete_zone(options = {})
77
+ raise ArgumentError, "API access information via :email and :token are required arguments" unless options[:email] && options[:token]
78
+ raise ArgumentError, ":id is a required argument" unless options[:id]
79
+ return auth_post("/api/xml/dns/#{options[:id]}/delete", :query => options)["hash"]["success"]
80
+ end
58
81
  end
59
- end
82
+ end
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <hash>
3
+ <success type="boolean">true</success>
4
+ </hash>
@@ -0,0 +1,7 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <hash>
3
+ <status>Active</status>
4
+ <ttl type="integer">86400</ttl>
5
+ <domain>example.com.</domain>
6
+ <id type="integer">171</id>
7
+ </hash>
@@ -7,15 +7,23 @@ class WebbynodeApiTest < Test::Unit::TestCase
7
7
  @api_key = "123456"
8
8
  data_path = File.join(File.dirname(__FILE__), "data")
9
9
  FakeWeb.clean_registry
10
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/client\?\w+/i, :body => File.read("#{data_path}/bad-auth.xml"))
10
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/client\?\w+/i, :body => File.read("#{data_path}/bad-auth.xml"))
11
+ FakeWeb.register_uri(:post, /^https:\/\/manager\.webbynode\.com\/.+$/, :body => "")
11
12
  end
12
13
  should "raise ArgumentError if no API data given" do
13
- assert_raise(ArgumentError){ WebbyNode::Client.new(nil, nil) }
14
- assert_raise(ArgumentError){ WebbyNode::Client.new(@email, nil) }
15
- assert_raise(ArgumentError){ WebbyNode::Client.new(nil, @api_key) }
14
+ # for auth_get
15
+ assert_raise(ArgumentError, "API information is missing or incomplete"){ WebbyNode::Client.new(nil, nil) }
16
+ assert_raise(ArgumentError, "API information is missing or incomplete"){ WebbyNode::Client.new(@email, nil) }
17
+ assert_raise(ArgumentError, "API information is missing or incomplete"){ WebbyNode::Client.new(nil, @api_key) }
18
+ # for auth_post
19
+ assert_raise(ArgumentError, "API information is missing or incomplete"){ WebbyNode::APIObject.new(nil, nil).auth_post("",{}) }
20
+ assert_raise(ArgumentError, "API information is missing or incomplete"){ WebbyNode::APIObject.new(@email, nil).auth_post("",{}) }
21
+ assert_raise(ArgumentError, "API information is missing or incomplete"){ WebbyNode::APIObject.new(nil, @api_key).auth_post("",{}) }
16
22
  end
17
23
  should "raise ArgumentError if bad API data given" do
18
- assert_raise(ArgumentError){ WebbyNode::Client.new(@email, @api_key) }
24
+ assert_raise(ArgumentError, "Probable bad API information given"){ WebbyNode::Client.new(@email, @api_key) }
25
+ # TODO: Need XML fixture file for this
26
+ # assert_raise(ArgumentError, "Probable bad API information given"){ WebbyNode::APIObject.new(@email, @api_key).auth_post("",{}) }
19
27
  end
20
28
  end
21
29
  context "fetching client data from API" do
@@ -24,7 +32,7 @@ class WebbynodeApiTest < Test::Unit::TestCase
24
32
  api_key = "123456"
25
33
  data_path = File.join(File.dirname(__FILE__), "data")
26
34
  FakeWeb.clean_registry
27
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/client\?.+/i, :body => File.read("#{data_path}/client.xml"))
35
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/client\?.+/i, :body => File.read("#{data_path}/client.xml"))
28
36
  @api = WebbyNode::Client.new(email, api_key)
29
37
  end
30
38
 
@@ -60,19 +68,17 @@ class WebbynodeApiTest < Test::Unit::TestCase
60
68
  end
61
69
  end
62
70
 
63
- context "fetching webbies data from API" do
71
+ context "fetching webbies data from API with hostname" do
64
72
  setup do
65
73
  email = "example@email.com"
66
74
  api_key = "123456"
67
75
  hostname = "webby1"
68
76
  data_path = File.join(File.dirname(__FILE__), "data")
69
77
  FakeWeb.clean_registry
70
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/start\?.+/i, :body => File.read("#{data_path}/webby-start.xml"))
71
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/shutdown\?.+/i, :body => File.read("#{data_path}/webby-shutdown.xml"))
72
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/reboot\?.+/i, :body => File.read("#{data_path}/webby-reboot.xml"))
73
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/webbies\?.+/i, :body => File.read("#{data_path}/webbies.xml"))
78
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/start\?.+/i, :body => File.read("#{data_path}/webby-start.xml"))
79
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/shutdown\?.+/i, :body => File.read("#{data_path}/webby-shutdown.xml"))
80
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/reboot\?.+/i, :body => File.read("#{data_path}/webby-reboot.xml"))
74
81
  @webby = WebbyNode::Webby.new(email, api_key, hostname)
75
- @webbies = WebbyNode::Webby.new(email, api_key)
76
82
  end
77
83
  should "return a job ID when starting, shutting down, or rebooting" do
78
84
  @webby.start.should == 2562
@@ -81,15 +87,25 @@ class WebbynodeApiTest < Test::Unit::TestCase
81
87
  end
82
88
  should "return a valid status" do
83
89
  data_path = File.join(File.dirname(__FILE__), "data")
84
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/status\?.+/i, :body => File.read("#{data_path}/webby-status.xml"))
90
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/status\?.+/i, :body => File.read("#{data_path}/webby-status.xml"))
85
91
  @webby.status.should == "on"
86
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/status\?.+/i, :body => File.read("#{data_path}/webby-status-shutdown.xml"))
92
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/status\?.+/i, :body => File.read("#{data_path}/webby-status-shutdown.xml"))
87
93
  @webby.status.should == "Shutting down"
88
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/status\?.+/i, :body => File.read("#{data_path}/webby-status-off.xml"))
94
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/status\?.+/i, :body => File.read("#{data_path}/webby-status-off.xml"))
89
95
  @webby.status.should == "off"
90
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/status\?.+/i, :body => File.read("#{data_path}/webby-status-reboot.xml"))
96
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/webby\/\w+\/status\?.+/i, :body => File.read("#{data_path}/webby-status-reboot.xml"))
91
97
  @webby.status.should == "Rebooting"
92
98
  end
99
+ end
100
+ context "fetching webbies data from API without hostname" do
101
+ setup do
102
+ email = "example@email.com"
103
+ api_key = "123456"
104
+ data_path = File.join(File.dirname(__FILE__), "data")
105
+ FakeWeb.clean_registry
106
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/webbies\?.+/i, :body => File.read("#{data_path}/webbies.xml"))
107
+ @webbies = WebbyNode::Webby.new(email, api_key)
108
+ end
93
109
  should "return a array of webbies" do
94
110
  @webbies.list.is_a?(Array).should be(true)
95
111
  webby = @webbies.list.first
@@ -108,8 +124,8 @@ class WebbynodeApiTest < Test::Unit::TestCase
108
124
  api_key = "123456"
109
125
  data_path = File.join(File.dirname(__FILE__), "data")
110
126
  FakeWeb.clean_registry
111
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/dns\?.+/i, :body => File.read("#{data_path}/dns.xml"))
112
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/dns\/\d+\?.+/i, :body => File.read("#{data_path}/dns-1.xml"))
127
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/dns\?.+/i, :body => File.read("#{data_path}/dns.xml"))
128
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/dns\/\d+\?.+/i, :body => File.read("#{data_path}/dns-1.xml"))
113
129
  @dns = WebbyNode::DNS.new(email, api_key)
114
130
  end
115
131
  should "return an array of zones with domain, status, id, and TTL" do
@@ -134,9 +150,9 @@ class WebbynodeApiTest < Test::Unit::TestCase
134
150
  id = 1
135
151
  data_path = File.join(File.dirname(__FILE__), "data")
136
152
  FakeWeb.clean_registry
137
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/dns\?.+/i, :body => File.read("#{data_path}/dns.xml"))
138
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/dns\/\d+\?.+/i, :body => File.read("#{data_path}/dns-1.xml"))
139
- FakeWeb.register_uri(:get, /https:\/\/manager\.webbynode\.com\/api\/xml\/dns\/\d+\/records\?.+/i, :body => File.read("#{data_path}/dns-records.xml"))
153
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/dns\?.+/i, :body => File.read("#{data_path}/dns.xml"))
154
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/dns\/\d+\?.+/i, :body => File.read("#{data_path}/dns-1.xml"))
155
+ FakeWeb.register_uri(:get, /^https:\/\/manager\.webbynode\.com\/api\/xml\/dns\/\d+\/records\?.+/i, :body => File.read("#{data_path}/dns-records.xml"))
140
156
  @dns = WebbyNode::DNS.new(email, api_key, id)
141
157
  end
142
158
  should "return domain name, status, id and TTL" do
@@ -147,6 +163,57 @@ class WebbynodeApiTest < Test::Unit::TestCase
147
163
  end
148
164
  should "return an array of records with id, type, name, data, auxiliary data, and TTL" do
149
165
  @dns.records.is_a?(Array).should be(true)
166
+ record = @dns.records.first
167
+ record["id"].should == 51
168
+ record["ttl"].should == 86400
169
+ record["data"].should == "200.100.200.100"
170
+ record["name"].should be(nil)
171
+ record["aux"].should == 0
172
+ record["type"].should == "A"
173
+ end
174
+ end
175
+ context "creating a new DNS zone" do
176
+ setup do
177
+ @email = "example@email.com"
178
+ @api_key = "123456"
179
+ @domain = "example.com."
180
+ @ttl = 86400
181
+ data_path = File.join(File.dirname(__FILE__), "data")
182
+ FakeWeb.clean_registry
183
+ FakeWeb.register_uri(:post, /^https:\/\/manager\.webbynode\.com\/api\/xml\/dns\/new\?.+/i, :body => File.read("#{data_path}/new-zone.xml"))
184
+ end
185
+ should "raise ArgumentError if API information isn't present" do
186
+ assert_raise(ArgumentError,"API access information via :email and :token are required arguments"){ WebbyNode::DNS.new_zone(:token => @api_key, :domain => @domain, :ttl => @ttl) }
187
+ assert_raise(ArgumentError,"API access information via :email and :token are required arguments"){ WebbyNode::DNS.new_zone(:email => @email, :domain => @domain, :ttl => @ttl) }
188
+ end
189
+ should "raise ArgumentError if :domain or :ttl aren't included in method call" do
190
+ assert_raise(ArgumentError, ":domain and :ttl are required arguments"){ WebbyNode::DNS.new_zone(:email => @email, :token => @api_key, :ttl => @ttl) }
191
+ assert_raise(ArgumentError, ":domain and :ttl are required arguments"){ WebbyNode::DNS.new_zone(:email => @email, :token => @api_key, :domain => @domain) }
192
+ end
193
+ should "raise ArgumentError if :status is not a valid option" do
194
+ assert_raise(ArgumentError, ":domain and :ttl are required arguments"){ WebbyNode::DNS.new_zone(:email => @email, :token => @api_key, :ttl => @ttl, :status => "Not active") }
195
+ assert_nothing_raised(ArgumentError){ WebbyNode::DNS.new_zone(:email => @email, :token => @api_key, :domain => @domain, :ttl => @ttl, :status => "Active") }
196
+ assert_nothing_raised(ArgumentError){ WebbyNode::DNS.new_zone(:email => @email, :token => @api_key, :domain => @domain, :ttl => @ttl, :status => "Inactive") }
197
+ assert_nothing_raised(ArgumentError){ WebbyNode::DNS.new_zone(:email => @email, :token => @api_key, :domain => @domain, :ttl => @ttl, :status => nil) }
198
+ end
199
+ should "return a Hash containing the new zone's information" do
200
+ @new_zone = WebbyNode::DNS.new_zone(:email => @email, :token => @api_key, :domain => @domain, :ttl => @ttl, :status => "Inactive")
201
+ @new_zone["id"].should == 171
202
+ @new_zone["domain"].should == "example.com."
203
+ @new_zone["ttl"].should == 86400
204
+ end
205
+ end
206
+ context "deleting a DNS zone" do
207
+ setup do
208
+ @email = "example@email.com"
209
+ @api_key = "123456"
210
+ @id = 171
211
+ data_path = File.join(File.dirname(__FILE__), "data")
212
+ FakeWeb.clean_registry
213
+ FakeWeb.register_uri(:post, /^https:\/\/manager\.webbynode\.com\/api\/xml\/dns\/\d+\/delete\?.+/i, :body => File.read("#{data_path}/delete-zone.xml"))
214
+ end
215
+ should "return a boolean true for succesful deletion" do
216
+ WebbyNode::DNS.delete_zone(:email => @email, :token => @api_key, :id => @id)
150
217
  end
151
218
  end
152
219
  end
@@ -2,11 +2,11 @@
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = %q{webbynode-api}
5
- s.version = "0.0.5"
5
+ s.version = "0.0.6"
6
6
 
7
7
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
8
8
  s.authors = ["Shane Sveller"]
9
- s.date = %q{2009-07-11}
9
+ s.date = %q{2009-07-12}
10
10
  s.email = %q{shanesveller@gmail.com}
11
11
  s.extra_rdoc_files = [
12
12
  "LICENSE",
@@ -23,9 +23,11 @@ Gem::Specification.new do |s|
23
23
  "lib/webbynode-api/data.rb",
24
24
  "test/data/bad-auth.xml",
25
25
  "test/data/client.xml",
26
+ "test/data/delete-zone.xml",
26
27
  "test/data/dns-1.xml",
27
28
  "test/data/dns-records.xml",
28
29
  "test/data/dns.xml",
30
+ "test/data/new-zone.xml",
29
31
  "test/data/webbies.xml",
30
32
  "test/data/webby-reboot.xml",
31
33
  "test/data/webby-shutdown.xml",
@@ -38,11 +40,10 @@ Gem::Specification.new do |s|
38
40
  "test/webbynode-api_test.rb",
39
41
  "webbynode-api.gemspec"
40
42
  ]
41
- s.has_rdoc = true
42
43
  s.homepage = %q{http://github.com/shanesveller/webbynode-api}
43
44
  s.rdoc_options = ["--charset=UTF-8"]
44
45
  s.require_paths = ["lib"]
45
- s.rubygems_version = %q{1.3.1}
46
+ s.rubygems_version = %q{1.3.4}
46
47
  s.summary = %q{wraps the WebbyNode API into nice Ruby objects}
47
48
  s.test_files = [
48
49
  "test/test_helper.rb",
@@ -51,7 +52,7 @@ Gem::Specification.new do |s|
51
52
 
52
53
  if s.respond_to? :specification_version then
53
54
  current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
54
- s.specification_version = 2
55
+ s.specification_version = 3
55
56
 
56
57
  if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
57
58
  else
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shanesveller-webbynode-api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.5
4
+ version: 0.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shane Sveller
@@ -9,7 +9,7 @@ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
 
12
- date: 2009-07-11 00:00:00 -07:00
12
+ date: 2009-07-12 00:00:00 -07:00
13
13
  default_executable:
14
14
  dependencies: []
15
15
 
@@ -33,9 +33,11 @@ files:
33
33
  - lib/webbynode-api/data.rb
34
34
  - test/data/bad-auth.xml
35
35
  - test/data/client.xml
36
+ - test/data/delete-zone.xml
36
37
  - test/data/dns-1.xml
37
38
  - test/data/dns-records.xml
38
39
  - test/data/dns.xml
40
+ - test/data/new-zone.xml
39
41
  - test/data/webbies.xml
40
42
  - test/data/webby-reboot.xml
41
43
  - test/data/webby-shutdown.xml
@@ -47,7 +49,7 @@ files:
47
49
  - test/test_helper.rb
48
50
  - test/webbynode-api_test.rb
49
51
  - webbynode-api.gemspec
50
- has_rdoc: true
52
+ has_rdoc: false
51
53
  homepage: http://github.com/shanesveller/webbynode-api
52
54
  post_install_message:
53
55
  rdoc_options:
@@ -71,7 +73,7 @@ requirements: []
71
73
  rubyforge_project:
72
74
  rubygems_version: 1.2.0
73
75
  signing_key:
74
- specification_version: 2
76
+ specification_version: 3
75
77
  summary: wraps the WebbyNode API into nice Ruby objects
76
78
  test_files:
77
79
  - test/test_helper.rb