miiCardConsumers 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,377 @@
1
+ require "oauth"
2
+ require "json"
3
+
4
+ # Describes the overall status of an API call.
5
+ module MiiApiCallStatus
6
+ # The API call succeeded - the associated result information can be found
7
+ # in the data property of the response object.
8
+ SUCCESS = 0
9
+ # The API call failed. You can get more information about the nature of
10
+ # the failure by examining the error_code property.
11
+ FAILURE = 1
12
+ end
13
+
14
+ # Details a specific problem that occurred when accessing the API.
15
+ module MiiApiErrorCode
16
+ # The API call succeeded.
17
+ SUCCESS = 0
18
+ # The user has revoked access to your application. The user would
19
+ # have to repeat the OAuth authorisation process before access would be
20
+ # restored to your application.
21
+ ACCESS_REVOKED = 100
22
+ # The user's miiCard subscription has elapsed. Only users with a current
23
+ # subscription can share their data with other applications and websites.
24
+ USER_SUBSCRIPTION_LAPSED = 200
25
+ # A general exception occurred during processing - details may be available
26
+ # in the error_message property of the response object depending upon the
27
+ # nature of the exception.
28
+ EXCEPTION = 10000
29
+ end
30
+
31
+ # Describes the overall status of an API call.
32
+ module WebPropertyType
33
+ # Indicates that the WebProperty relates to a domain name.
34
+ DOMAIN = 0
35
+ # Indicates that the WebProperty relates to a website.
36
+ WEBSITE = 1
37
+ end
38
+
39
+ # Base class for most verifiable identity data.
40
+ class Claim
41
+ attr_accessor :verified
42
+
43
+ def initialize(verified)
44
+ @verified = verified
45
+ end
46
+ end
47
+
48
+ class Identity < Claim
49
+ attr_accessor :source, :user_id, :profile_url
50
+
51
+ def initialize(verified, source, user_id, profile_url)
52
+ super(verified)
53
+
54
+ @source = source
55
+ @user_id = user_id
56
+ @profile_url = profile_url
57
+ end
58
+
59
+ def self.from_hash(hash)
60
+ return Identity.new(
61
+ hash["Verified"],
62
+ hash["Source"],
63
+ hash["UserId"],
64
+ hash["ProfileUrl"]
65
+ )
66
+ end
67
+ end
68
+
69
+ class EmailAddress < Claim
70
+ attr_accessor :display_name, :address, :is_primary
71
+
72
+ def initialize(verified, display_name, address, is_primary)
73
+ super(verified)
74
+
75
+ @display_name = display_name
76
+ @address = address
77
+ @is_primary = is_primary
78
+ end
79
+
80
+ def self.from_hash(hash)
81
+ return EmailAddress.new(
82
+ hash["Verified"],
83
+ hash["DisplayName"],
84
+ hash["Address"],
85
+ hash["IsPrimary"]
86
+ )
87
+ end
88
+ end
89
+
90
+ class PhoneNumber < Claim
91
+ attr_accessor :display_name, :country_code, :national_number, :is_mobile, :is_primary
92
+
93
+ def initialize(verified, display_name, country_code, national_number, is_mobile, is_primary)
94
+ super(verified)
95
+
96
+ @display_name = display_name
97
+ @country_code = country_code
98
+ @national_number = national_number
99
+ @is_mobile = is_mobile
100
+ @is_primary = is_primary
101
+ end
102
+
103
+ def self.from_hash(hash)
104
+ return PhoneNumber.new(
105
+ hash["Verified"],
106
+ hash["DisplayName"],
107
+ hash["CountryCode"],
108
+ hash["NationalNumber"],
109
+ hash["IsMobile"],
110
+ hash["IsPrimary"]
111
+ )
112
+ end
113
+ end
114
+
115
+ class PostalAddress < Claim
116
+ attr_accessor :house, :line1, :line2, :city, :region, :code, :country, :is_primary
117
+
118
+ def initialize(verified, house, line1, line2, city, region, code, country, is_primary)
119
+ super(verified)
120
+
121
+ @house = house
122
+ @line1 = line1
123
+ @line2 = line2
124
+ @city = city
125
+ @region = region
126
+ @code = code
127
+ @country = country
128
+ @is_primary = is_primary
129
+ end
130
+
131
+ def self.from_hash(hash)
132
+ return PostalAddress.new(
133
+ hash["Verified"],
134
+ hash["House"],
135
+ hash["Line1"],
136
+ hash["Line2"],
137
+ hash["City"],
138
+ hash["Region"],
139
+ hash["Code"],
140
+ hash["Country"],
141
+ hash["IsPrimary"]
142
+ )
143
+ end
144
+ end
145
+
146
+ class WebProperty < Claim
147
+ attr_accessor :display_name, :identifier, :type
148
+
149
+ def initialize(verified, display_name, identifier, type)
150
+ super(verified)
151
+
152
+ @display_name = display_name
153
+ @identifier = identifier
154
+ @type = type
155
+ end
156
+
157
+ def self.from_hash(hash)
158
+ return WebProperty.new(
159
+ hash["Verified"],
160
+ hash["DisplayName"],
161
+ hash["Identifier"],
162
+ hash["Type"]
163
+ )
164
+ end
165
+ end
166
+
167
+ class MiiUserProfile
168
+ attr_accessor :username, :salutation, :first_name, :middle_name, :last_name
169
+ attr_accessor :previous_first_name, :previous_middle_name, :previous_last_name
170
+ attr_accessor :last_verified, :profile_url, :profile_short_url, :card_image_url, :email_addresses, :identities, :postal_addresses
171
+ attr_accessor :phone_numbers, :web_properties, :identity_assured, :has_public_profile
172
+ attr_accessor :public_profile
173
+
174
+ def initialize(
175
+ username,
176
+ salutation,
177
+ first_name,
178
+ middle_name,
179
+ last_name,
180
+ previous_first_name,
181
+ previous_middle_name,
182
+ previous_last_name,
183
+ last_verified,
184
+ profile_url,
185
+ profile_short_url,
186
+ card_image_url,
187
+ email_addresses,
188
+ identities,
189
+ phone_numbers,
190
+ postal_addresses,
191
+ web_properties,
192
+ identity_assured,
193
+ has_public_profile,
194
+ public_profile
195
+ )
196
+
197
+ @username= username
198
+ @salutation = salutation
199
+ @first_name = first_name
200
+ @middle_name = middle_name
201
+ @last_name = last_name
202
+ @previous_first_name = previous_first_name
203
+ @previous_middle_name = previous_middle_name
204
+ @previous_last_name = previous_last_name
205
+ @last_verified = last_verified
206
+ @profile_url = profile_url
207
+ @profile_short_url = profile_short_url
208
+ @card_image_url = card_image_url
209
+ @email_addresses = email_addresses
210
+ @identities = identities
211
+ @phone_numbers = phone_numbers
212
+ @postal_addresses = postal_addresses
213
+ @web_properties = web_properties
214
+ @identity_assured = identity_assured
215
+ @has_public_profile = has_public_profile
216
+ @public_profile = public_profile
217
+ end
218
+
219
+ def self.from_hash(hash)
220
+ emails = hash["EmailAddresses"]
221
+ emails_parsed = nil
222
+ unless (emails.nil? || emails.empty?)
223
+ emails_parsed = emails.map{|item| EmailAddress::from_hash(item)}
224
+ end
225
+
226
+ identities = hash["Identities"]
227
+ identities_parsed = nil
228
+ unless (identities.nil? || identities.empty?)
229
+ identities_parsed = identities.map{|item| Identity::from_hash(item)}
230
+ end
231
+
232
+ phone_numbers = hash["PhoneNumbers"]
233
+ phone_numbers_parsed = nil
234
+ unless (phone_numbers.nil? || phone_numbers.empty?)
235
+ phone_numbers_parsed = phone_numbers.map{|item| PhoneNumber::from_hash(item)}
236
+ end
237
+
238
+ postal_addresses = hash["PostalAddresses"]
239
+ postal_addresses_parsed = nil
240
+ unless (postal_addresses.nil? || postal_addresses.empty?)
241
+ postal_addresses_parsed = postal_addresses.map{|item| PostalAddress::from_hash(item)}
242
+ end
243
+
244
+ web_properties = hash["WebProperties"]
245
+ web_properties_parsed = nil
246
+ unless (web_properties.nil? || web_properties.empty?)
247
+ web_properties_parsed = web_properties.map{|item| WebProperty::from_hash(item)}
248
+ end
249
+
250
+ public_profile = hash["PublicProfile"]
251
+ public_profile_parsed = nil
252
+ unless public_profile.nil?
253
+ public_profile_parsed = MiiUserProfile::from_hash(public_profile)
254
+ end
255
+
256
+ return MiiUserProfile.new(
257
+ hash['Username'],
258
+ hash['Salutation'],
259
+ hash['FirstName'],
260
+ hash['MiddleName'],
261
+ hash['LastName'],
262
+ hash['PreviousFirstName'],
263
+ hash['PreviousMiddleName'],
264
+ hash['PreviousLastName'],
265
+ hash['LastVerified'],
266
+ hash['ProfileUrl'],
267
+ hash['ProfileShortUrl'],
268
+ hash['CardImageUrl'],
269
+ emails_parsed,
270
+ identities_parsed,
271
+ phone_numbers_parsed,
272
+ postal_addresses_parsed,
273
+ web_properties_parsed,
274
+ hash['IdentityAssured'],
275
+ hash['HasPublicProfile'],
276
+ public_profile_parsed
277
+ )
278
+ end
279
+ end
280
+
281
+ class MiiApiResponse
282
+ attr_accessor :status, :error_code, :error_message, :data
283
+
284
+ def initialize(status, error_code, error_message, data)
285
+ @status = status
286
+ @error_code = error_code
287
+ @error_message = error_message
288
+ @data = data
289
+ end
290
+
291
+ def self.from_hash(hash, data_processor)
292
+ payload_json = hash["Data"]
293
+
294
+ if payload_json && !data_processor.nil?
295
+ payload = data_processor.call(payload_json)
296
+ elsif !(payload_json.nil?)
297
+ payload = payload_json
298
+ else
299
+ payload = nil
300
+ end
301
+
302
+ return MiiApiResponse.new(
303
+ hash["Status"],
304
+ hash["ErrorCode"],
305
+ hash["ErrorMessage"],
306
+ payload
307
+ )
308
+ end
309
+ end
310
+
311
+ class MiiCardOAuthServiceBase
312
+ attr_accessor :consumer_key, :consumer_secret, :access_token, :access_token_secret
313
+
314
+ def initialize(consumer_key, consumer_secret, access_token, access_token_secret)
315
+ if consumer_key.nil? || consumer_secret.nil? || access_token.nil? || access_token_secret.nil?
316
+ raise ArgumentError
317
+ end
318
+
319
+ @consumer_key = consumer_key
320
+ @consumer_secret = consumer_secret
321
+ @access_token = access_token
322
+ @access_token_secret = access_token_secret
323
+ end
324
+ end
325
+
326
+ class MiiCardOAuthClaimsService < MiiCardOAuthServiceBase
327
+ def initialize(consumer_key, consumer_secret, access_token, access_token_secret)
328
+ super(consumer_key, consumer_secret, access_token, access_token_secret)
329
+ end
330
+
331
+ def get_claims
332
+ return make_request(MiiCardServiceUrls.get_method_url('GetClaims'), nil, MiiUserProfile.method(:from_hash), true)
333
+ end
334
+
335
+ def is_social_account_assured(social_account_id, social_account_type)
336
+ params = Hash["socialAccountId", social_account_id, "socialAccountType", social_account_type]
337
+
338
+ return make_request(MiiCardServiceUrls.get_method_url('IsSocialAccountAssured'), params, nil, true)
339
+ end
340
+
341
+ def is_user_assured
342
+ return make_request(MiiCardServiceUrls.get_method_url('IsUserAssured'), nil, nil, true)
343
+ end
344
+
345
+ def assurance_image(type)
346
+ params = Hash["type", type]
347
+
348
+ return make_request(MiiCardServiceUrls.get_method_url('AssuranceImage'), params, nil, false)
349
+ end
350
+
351
+ private
352
+ def make_request(url, post_data, payload_processor, wrapped_response)
353
+ consumer = OAuth::Consumer.new(@consumer_key, @consumer_secret, {:site => MiiCardServiceUrls::STS_SITE, :request_token_path => MiiCardServiceUrls::OAUTH_ENDPOINT, :access_token_path => MiiCardServiceUrls::OAUTH_ENDPOINT, :authorize_path => MiiCardServiceUrls::OAUTH_ENDPOINT })
354
+ access_token = OAuth::AccessToken.new(consumer, @access_token, @access_token_secret)
355
+
356
+ response = access_token.post(url, post_data.to_json(), { 'Content-Type' => 'application/json' })
357
+
358
+ if wrapped_response
359
+ return MiiApiResponse::from_hash(JSON.parse(response.body), payload_processor)
360
+ elsif !payload_processor.nil?
361
+ return payload_processor.call(response.body)
362
+ else
363
+ return response.body
364
+ end
365
+ end
366
+ end
367
+
368
+ class MiiCardServiceUrls
369
+ OAUTH_ENDPOINT = "https://sts.miicard.com/auth/OAuth.ashx"
370
+ STS_SITE = "https://sts.miicard.com"
371
+ CLAIMS_SVC = "https://sts.miicard.com/api/v1/Claims.svc/json"
372
+
373
+ def self.get_method_url(method_name)
374
+ return MiiCardServiceUrls::CLAIMS_SVC + "/" + method_name
375
+ end
376
+ end
377
+
@@ -0,0 +1,137 @@
1
+ require 'json'
2
+ require 'miiCardConsumers'
3
+ require 'test/unit'
4
+
5
+ class TestSomething < Test::Unit::TestCase
6
+ def setup
7
+ @jsonBody = '{"CardImageUrl":"https:\/\/my.miicard.com\/img\/test.png","EmailAddresses":[{"Verified":true,"Address":"test@example.com","DisplayName":"testEmail","IsPrimary":true},{"Verified":false,"Address":"test2@example.com","DisplayName":"test2Email","IsPrimary":false}],"FirstName":"Test","HasPublicProfile":true,"Identities":null,"IdentityAssured":true,"LastName":"User","LastVerified":"\/Date(1345812103)\/","MiddleName":"Middle","PhoneNumbers":[{"Verified":true,"CountryCode":"44","DisplayName":"Default","IsMobile":true,"IsPrimary":true,"NationalNumber":"7800123456"},{"Verified":false,"CountryCode":"44","DisplayName":"Default","IsMobile":false,"IsPrimary":false,"NationalNumber":"7800123457"}],"PostalAddresses":[{"House":"Addr1 House1","Line1":"Addr1 Line1","Line2":"Addr1 Line2","City":"Addr1 City","Region":"Addr1 Region","Code":"Addr1 Code","Country":"Addr1 Country","IsPrimary":true,"Verified":true},{"House":"Addr2 House1","Line1":"Addr2 Line1","Line2":"Addr2 Line2","City":"Addr2 City","Region":"Addr2 Region","Code":"Addr2 Code","Country":"Addr2 Country","IsPrimary":false,"Verified":false}],"PreviousFirstName":"PrevFirst","PreviousLastName":"PrevLast","PreviousMiddleName":"PrevMiddle","ProfileShortUrl":"http:\/\/miicard.me\/123456","ProfileUrl":"https:\/\/my.miicard.com\/card\/test","PublicProfile":{"CardImageUrl":"https:\/\/my.miicard.com\/img\/test.png","FirstName":"Test","HasPublicProfile":true,"IdentityAssured":true,"LastName":"User","LastVerified":"\/Date(1345812103)\/","MiddleName":"Middle","PreviousFirstName":"PrevFirst","PreviousLastName":"PrevLast","PreviousMiddleName":"PrevMiddle","ProfileShortUrl":"http:\/\/miicard.me\/123456","ProfileUrl":"https:\/\/my.miicard.com\/card\/test","PublicProfile":null,"Salutation":"Ms","Username":"testUser"},"Salutation":"Ms","Username":"testUser","WebProperties":[{"Verified":true,"DisplayName":"example.com","Identifier":"example.com","Type":0},{"Verified":false,"DisplayName":"2.example.com","Identifier":"http:\/\/www.2.example.com","Type":1}]}'
8
+ @jsonResponseBody = '{"ErrorCode":0,"Status":0,"ErrorMessage":"A test error message","Data":true}'
9
+ end
10
+
11
+ def test_can_deserialise_user_profile
12
+ o = MiiUserProfile.from_hash(JSON.parse(@jsonBody))
13
+
14
+ assertBasics(o)
15
+
16
+ # Email addresses
17
+ email1 = o.email_addresses[0]
18
+ assert_equal(true, email1.verified)
19
+ assert_equal("test@example.com", email1.address)
20
+ assert_equal("testEmail", email1.display_name)
21
+ assert_equal(true, email1.is_primary)
22
+
23
+ email2 = o.email_addresses[1]
24
+ assert_equal(false, email2.verified)
25
+ assert_equal("test2@example.com", email2.address)
26
+ assert_equal("test2Email", email2.display_name)
27
+ assert_equal(false, email2.is_primary)
28
+
29
+ # Phone numbers
30
+ phone1 = o.phone_numbers[0]
31
+ assert_equal(true, phone1.verified)
32
+ assert_equal("44", phone1.country_code)
33
+ assert_equal("Default", phone1.display_name)
34
+ assert_equal(true, phone1.is_mobile)
35
+ assert_equal(true, phone1.is_primary)
36
+ assert_equal("7800123456", phone1.national_number)
37
+
38
+ phone2 = o.phone_numbers[1]
39
+ assert_equal(false, phone2.verified)
40
+ assert_equal("44", phone2.country_code)
41
+ assert_equal("Default", phone2.display_name)
42
+ assert_equal(false, phone2.is_mobile)
43
+ assert_equal(false, phone2.is_primary)
44
+ assert_equal("7800123457", phone2.national_number)
45
+
46
+ # Web properties
47
+ prop1 = o.web_properties[0]
48
+ assert_equal(true, prop1.verified)
49
+ assert_equal("example.com", prop1.display_name)
50
+ assert_equal("example.com", prop1.identifier)
51
+ assert_equal(WebPropertyType::DOMAIN, prop1.type)
52
+
53
+ prop2 = o.web_properties[1]
54
+ assert_equal(false, prop2.verified)
55
+ assert_equal("2.example.com", prop2.display_name)
56
+ assert_equal("http://www.2.example.com", prop2.identifier)
57
+ assert_equal(WebPropertyType::WEBSITE, prop2.type)
58
+
59
+ # Postal addresses
60
+ addr1 = o.postal_addresses[0]
61
+ assert_equal("Addr1 House1", addr1.house)
62
+ assert_equal("Addr1 Line1", addr1.line1)
63
+ assert_equal("Addr1 Line2", addr1.line2)
64
+ assert_equal("Addr1 City", addr1.city)
65
+ assert_equal("Addr1 Region", addr1.region)
66
+ assert_equal("Addr1 Code", addr1.code)
67
+ assert_equal("Addr1 Country", addr1.country)
68
+ assert_equal(true, addr1.is_primary)
69
+ assert_equal(true, addr1.verified)
70
+
71
+ addr2 = o.postal_addresses[1]
72
+ assert_equal("Addr2 House1", addr2.house)
73
+ assert_equal("Addr2 Line1", addr2.line1)
74
+ assert_equal("Addr2 Line2", addr2.line2)
75
+ assert_equal("Addr2 City", addr2.city)
76
+ assert_equal("Addr2 Region", addr2.region)
77
+ assert_equal("Addr2 Code", addr2.code)
78
+ assert_equal("Addr2 Country", addr2.country)
79
+ assert_equal(false, addr2.is_primary)
80
+ assert_equal(false, addr2.verified)
81
+
82
+ assert_equal(true, o.has_public_profile)
83
+
84
+ pp = o.public_profile
85
+ assertBasics(pp)
86
+ assert_equal("testUser", pp.username)
87
+ end
88
+
89
+ def assertBasics(obj)
90
+ assert_not_nil(obj)
91
+
92
+ assert_equal("https://my.miicard.com/img/test.png", obj.card_image_url)
93
+ assert_equal("Test", obj.first_name)
94
+ assert_equal("Middle", obj.middle_name)
95
+ assert_equal("User", obj.last_name)
96
+
97
+ assert_equal("PrevFirst", obj.previous_first_name)
98
+ assert_equal("PrevMiddle", obj.previous_middle_name)
99
+ assert_equal("PrevLast", obj.previous_last_name)
100
+
101
+ assert_equal(true, obj.identity_assured)
102
+ assert_equal("/Date(1345812103)/", obj.last_verified)
103
+
104
+ assert_equal(true, obj.has_public_profile)
105
+ assert_equal("http://miicard.me/123456", obj.profile_short_url)
106
+ assert_equal("https://my.miicard.com/card/test", obj.profile_url)
107
+ assert_equal("Ms", obj.salutation)
108
+ assert_equal("testUser", obj.username)
109
+ end
110
+
111
+ def test_can_deserialise_boolean
112
+ o = MiiApiResponse.from_hash(JSON.parse(@jsonResponseBody), nil)
113
+
114
+ assert_equal(MiiApiCallStatus::SUCCESS, o.status)
115
+ assert_equal(MiiApiErrorCode::SUCCESS, o.error_code)
116
+ assert_equal("A test error message", o.error_message)
117
+ assert_equal(true, o.data)
118
+ end
119
+
120
+ def test_wrapper_throws_on_null_consumer_key
121
+ assert_raise ArgumentError do
122
+ MiiCardOAuthClaimsService.new(nil, "ConsumerSecret", "AccessToken", "AccessTokenSecret")
123
+ end
124
+
125
+ assert_raise ArgumentError do
126
+ MiiCardOAuthClaimsService.new("ConsumerKey", nil, "AccessToken", "AccessTokenSecret")
127
+ end
128
+
129
+ assert_raise ArgumentError do
130
+ MiiCardOAuthClaimsService.new("ConsumerKey", "ConsumerSecret", nil, "AccessTokenSecret")
131
+ end
132
+
133
+ assert_raise ArgumentError do
134
+ MiiCardOAuthClaimsService.new("ConsumerKey", "ConsumerSecret", "AccessToken", nil)
135
+ end
136
+ end
137
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: miiCardConsumers
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Paul O'Neill
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-08-14 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: oauth
16
+ requirement: &27018276 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: *27018276
25
+ - !ruby/object:Gem::Dependency
26
+ name: json
27
+ requirement: &27018012 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: *27018012
36
+ description: A simple wrapper library around the miiCard API that makes calling into
37
+ it easier - just new up a MiiApiOAuthClaimsService with your consumer key, secret,
38
+ access token and secret and start calling methods.
39
+ email: paul.oneill@miicard.com
40
+ executables: []
41
+ extensions: []
42
+ extra_rdoc_files: []
43
+ files:
44
+ - lib/miiCardConsumers.rb
45
+ - test/test_miiCardConsumers.rb
46
+ homepage: http://www.miicard.com/developers
47
+ licenses: []
48
+ post_install_message:
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ none: false
54
+ requirements:
55
+ - - ! '>='
56
+ - !ruby/object:Gem::Version
57
+ version: '0'
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ none: false
60
+ requirements:
61
+ - - ! '>='
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubyforge_project:
66
+ rubygems_version: 1.7.2
67
+ signing_key:
68
+ specification_version: 3
69
+ summary: Wrapper library around the miiCard API.
70
+ test_files:
71
+ - test/test_miiCardConsumers.rb