rack-berater 0.4.1 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7cdec5112bd0a056e38bba20ca0c6c25c84f6703f1c031469b815806ef7f25a
4
- data.tar.gz: 390634760abee230969aa03f51ad7eb86dc3372f17d4d0c1597cb759db4804fa
3
+ metadata.gz: 879e22ae9d610e5c47f9866c76409c71cd335558c79d69e43bce71bea1f0d8b8
4
+ data.tar.gz: 7a04e12d16de13936a6ec8e0bad8130c1ab465e5625627d5be9cf24e5e694e8b
5
5
  SHA512:
6
- metadata.gz: cae54a952dec822b533f3f16d555cc5215e888f0783c3fc5d9d266cc8f7b7f14884068e603e19e4e2c1880adcff1bd2adbf73de025cc93095da14b105f89cc8e
7
- data.tar.gz: 44b0008bc762dfd16b28a9a731ee8c5be5a766e48341828b7c49df9967fbb48cc213955c199b61cbd5abae2bae7e5b4b207ce46e8e6afdd22564d916be49f73a
6
+ metadata.gz: 357bc1725eb43ae4ea0a664c648b78aecfd89648606e62b24fac3ff799b19464819f68bdf550ee3f9dc57757377b6c66c5fbea6914a4d45043e830ba427b38fe
7
+ data.tar.gz: 1472454e024edbb12637a886a4e1c91553ae6ac094278801428cec7dc6f8c52a3dad7218acaf811efd9b903390930e9c55c6476eaf9b795672f98c91e80a55fb
@@ -0,0 +1,31 @@
1
+ module Rack
2
+ class Berater
3
+ class GraphqlPrioritizer < Prioritizer
4
+ def cache_key_for(env)
5
+ request = Rack::Request.new(env)
6
+
7
+ if request.path == '/graphql'
8
+ parts = [ 'graphql' ]
9
+
10
+ if request.params['operationName']
11
+ parts << request.params['operationName']
12
+ elsif request.params['query']
13
+ op = begin
14
+ GraphQL.parse(request.params['query']).definitions.find do |x|
15
+ x.is_a? GraphQL::Language::Nodes::OperationDefinition
16
+ end
17
+ rescue GraphQL::ParseError
18
+ nil
19
+ end
20
+
21
+ parts << op.name if op
22
+ end
23
+
24
+ parts.join(':')
25
+ else
26
+ super
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -9,15 +9,13 @@ module Rack
9
9
  def initialize(app, options = {})
10
10
  @app = app
11
11
  @header = options[:header] || HEADER
12
-
13
- synchronize { @@cache ||= {} }
14
12
  end
15
13
 
16
14
  def call(env)
17
15
  priority = env[@header] || env["HTTP_#{@header.upcase.tr('-', '_')}"]
18
16
 
19
17
  if priority
20
- set_priority(priority)
18
+ self.priority = priority
21
19
  return @app.call(env)
22
20
  end
23
21
 
@@ -25,11 +23,11 @@ module Rack
25
23
  cached_priority = cache_get(cache_key)
26
24
 
27
25
  if cached_priority
28
- set_priority(cached_priority)
26
+ self.priority = cached_priority
29
27
  end
30
28
 
31
29
  @app.call(env).tap do |status, headers, body|
32
- app_priority = headers.delete(@header) if headers
30
+ app_priority = (headers.delete(@header) || headers.delete(@header.downcase)) if headers
33
31
 
34
32
  if app_priority && app_priority != cached_priority
35
33
  # update cache for next time
@@ -46,19 +44,20 @@ module Rack
46
44
 
47
45
  protected
48
46
 
49
- def set_priority(priority)
47
+ def priority=(priority)
50
48
  Thread.current[ENV_KEY] = priority
51
49
  end
52
50
 
53
51
  def cache_key_for(env)
54
52
  [
55
- env[Rack::REQUEST_METHOD].downcase,
53
+ env[Rack::REQUEST_METHOD],
56
54
 
57
55
  # normalize RESTful paths
58
56
  env['PATH_INFO'].gsub(%r{/[0-9]+(/|$)}, '/x\1'),
59
57
  ].join(':')
60
58
  end
61
59
 
60
+ @@cache = {}
62
61
  def cache_get(key)
63
62
  synchronize { @@cache[key] }
64
63
  end
@@ -1,6 +1,3 @@
1
- require 'action_controller/metal'
2
- require 'action_dispatch'
3
-
4
1
  module Rack
5
2
  class Berater
6
3
  class RailsPrioritizer < Prioritizer
@@ -9,7 +6,7 @@ module Rack
9
6
  env[Rack::PATH_INFO],
10
7
  method: env[Rack::REQUEST_METHOD],
11
8
  ).values_at(:controller, :action).compact.join('#')
12
- rescue ActionController::RoutingError
9
+ rescue ActionController::RoutingError, NoMethodError
13
10
  super
14
11
  end
15
12
  end
@@ -4,6 +4,7 @@ require 'rspec/core'
4
4
  RSpec.configure do |config|
5
5
  config.after do
6
6
  Thread.current[Rack::Berater::Prioritizer::ENV_KEY] = nil
7
+
7
8
  Rack::Berater::Prioritizer.class_variable_get(:@@cache).clear
8
9
  end
9
10
  end
@@ -1,5 +1,5 @@
1
1
  module Rack
2
2
  class Berater
3
- VERSION = "0.4.1"
3
+ VERSION = "0.5.0"
4
4
  end
5
5
  end
data/lib/rack/berater.rb CHANGED
@@ -5,6 +5,7 @@ require 'set'
5
5
 
6
6
  module Rack
7
7
  class Berater
8
+ autoload :GraphqlPrioritizer, 'rack/berater/graphql_prioritizer'
8
9
  autoload :Prioritizer, 'rack/berater/prioritizer'
9
10
  autoload :RailsPrioritizer, 'rack/berater/rails_prioritizer'
10
11
  autoload :Railtie, 'rack/berater/railtie'
@@ -0,0 +1,104 @@
1
+ require 'graphql'
2
+
3
+ class UserType < GraphQL::Schema::Object
4
+ field :name, String, null: false
5
+ end
6
+
7
+ class QueryType < GraphQL::Schema::Object
8
+ field :me, UserType
9
+
10
+ def me
11
+ { name: 'me' }
12
+ end
13
+
14
+ field :user, UserType do
15
+ argument :id, ID
16
+ end
17
+
18
+ def user(id:)
19
+ { name: "user_#{id}" }
20
+ end
21
+ end
22
+
23
+ class Schema < GraphQL::Schema
24
+ query QueryType
25
+ end
26
+
27
+ class GraphqlController < ActionController::Base
28
+ def execute
29
+ render json: Schema.execute(
30
+ params[:query],
31
+ variables: params[:variables],
32
+ operation_name: params[:operationName]
33
+ )
34
+ end
35
+ end
36
+
37
+ describe Rack::Berater::GraphqlPrioritizer do
38
+ before do
39
+ Rails.application = Class.new(Rails::Application) do
40
+ config.eager_load = false
41
+ config.hosts.clear # disable hostname filtering
42
+ # config.logger = ActiveSupport::Logger.new($stdout)
43
+ end
44
+ Rails.application.middleware.use described_class
45
+ Rails.initialize!
46
+
47
+ Rails.application.routes.draw do
48
+ post '/graphql', to: 'graphql#execute'
49
+ end
50
+ end
51
+
52
+ let(:app) { Rails.application }
53
+ let(:query) { 'query Me { me { name } }'}
54
+
55
+ describe '#cache_key_for' do
56
+ subject { described_class.new(app).method(:cache_key_for) }
57
+
58
+ it 'uses the implicit graphql operation name' do
59
+ expect(
60
+ subject.call(Rack::MockRequest.env_for(
61
+ '/graphql',
62
+ params: {
63
+ query: query,
64
+ },
65
+ ))
66
+ ).to match /graphql:Me/
67
+ end
68
+
69
+ it 'uses the explicit graphql operation name param' do
70
+ expect(
71
+ subject.call(Rack::MockRequest.env_for(
72
+ '/graphql',
73
+ params: {
74
+ query: query,
75
+ operationName: 'What',
76
+ },
77
+ ))
78
+ ).to match /graphql:What/
79
+ end
80
+
81
+ it 'works when operation name is missing' do
82
+ expect(
83
+ subject.call(Rack::MockRequest.env_for('/graphql'))
84
+ ).to match /graphql/
85
+ end
86
+
87
+ it 'works when query is invalid' do
88
+ expect(
89
+ subject.call(Rack::MockRequest.env_for(
90
+ '/graphql',
91
+ params: {
92
+ query: 'abc!!!',
93
+ },
94
+ ))
95
+ ).to match /graphql/
96
+ end
97
+
98
+ it 'falls back to Rack style names' do
99
+ expect(
100
+ subject.call(Rack::MockRequest.env_for('/foo'))
101
+ ).to match %r{GET:/foo}
102
+ end
103
+ end
104
+ end
data/spec/limiter_spec.rb CHANGED
@@ -9,7 +9,7 @@ describe Rack::Berater do
9
9
  Rack::Builder.new do
10
10
  use Rack::Lint
11
11
  run (lambda do |env|
12
- [200, {'Content-Type' => 'text/plain'}, ['OK']]
12
+ [200, {'content-type' => 'text/plain'}, ['OK']]
13
13
  end)
14
14
  end
15
15
  end
@@ -22,8 +22,8 @@ describe Rack::Berater do
22
22
 
23
23
  it 'has the correct headers' do
24
24
  expect(response.headers).to eq({
25
- 'Content-Type' => 'text/plain',
26
- 'Content-Length' => '2',
25
+ 'content-type' => 'text/plain',
26
+ 'content-length' => '2',
27
27
  })
28
28
  end
29
29
 
@@ -50,7 +50,7 @@ describe Rack::Berater do
50
50
  end
51
51
 
52
52
  context 'when operating beyond limits' do
53
- before { Berater.test_mode = :fail }
53
+ let(:limiter) { ::Berater::Inhibitor.new }
54
54
 
55
55
  it 'returns an error' do
56
56
  expect(response.status).to eq 429
@@ -59,8 +59,7 @@ describe Rack::Berater do
59
59
  end
60
60
 
61
61
  context 'when limiter is a proc' do
62
- let(:limiter_instance) { ::Berater::Unlimiter.new }
63
- let(:limiter) { Proc.new { limiter_instance } }
62
+ let(:limiter) { Proc.new { ::Berater::Unlimiter.new } }
64
63
 
65
64
  include_examples 'works nominally'
66
65
 
@@ -70,7 +69,7 @@ describe Rack::Berater do
70
69
  end
71
70
 
72
71
  context 'when operating beyond limits' do
73
- before { Berater.test_mode = :fail }
72
+ let(:limiter) { Proc.new { ::Berater::Inhibitor.new } }
74
73
 
75
74
  it 'returns an error' do
76
75
  expect(response.status).to eq 429
@@ -1,10 +1,8 @@
1
1
  describe Rack::Berater::Prioritizer do
2
- let(:cache) { described_class.class_variable_get(:@@cache) }
3
-
4
2
  describe '#call' do
5
3
  subject { described_class.new(app) }
6
4
 
7
- let(:app) { ->(*) { [200, {'Content-Type' => 'text/plain'}, ['OK']] } }
5
+ let(:app) { ->(*) { [200, {'content-type' => 'text/plain'}, ['OK']] } }
8
6
  let(:cache_key) { subject.send(:cache_key_for, env) }
9
7
  let(:env) { Rack::MockRequest.env_for('/') }
10
8
 
@@ -30,7 +28,7 @@ describe Rack::Berater::Prioritizer do
30
28
  after { subject.call(env) }
31
29
 
32
30
  it 'sets the priority accordingly' do
33
- is_expected.to receive(:set_priority).with(priority)
31
+ is_expected.to receive(:priority=).with(priority)
34
32
  end
35
33
 
36
34
  it 'updates the global priority during the request' do
@@ -57,12 +55,12 @@ describe Rack::Berater::Prioritizer do
57
55
  after { subject.call(env) }
58
56
 
59
57
  it 'uses the header' do
60
- is_expected.to receive(:set_priority).with(priority)
58
+ is_expected.to receive(:priority=).with(priority)
61
59
  end
62
60
 
63
61
  it 'ignores any cached value' do
64
62
  allow(subject).to receive(:cache_get).with(cache_key).and_return('123')
65
- is_expected.to receive(:set_priority).with(priority)
63
+ is_expected.to receive(:priority=).with(priority)
66
64
  end
67
65
 
68
66
  it 'resets the priority after the request completes' do
@@ -100,6 +98,21 @@ describe Rack::Berater::Prioritizer do
100
98
  end
101
99
  end
102
100
 
101
+ context 'when the app returns a lowercase priority header, per Rack 3' do
102
+ let(:app) do
103
+ ->(*) { [200, { described_class::HEADER.downcase => priority }, ['OK']] }
104
+ end
105
+
106
+ let(:priority) { '5' }
107
+
108
+ it 'caches the priority and removes the header' do
109
+ is_expected.to receive(:cache_set).with(cache_key, priority)
110
+
111
+ _, headers, _ = subject.call(env)
112
+ expect(headers).to be_empty
113
+ end
114
+ end
115
+
103
116
  it 'does not update the cache when no priority is returned' do
104
117
  is_expected.not_to receive(:cache_set)
105
118
  subject.call(env)
@@ -113,7 +126,7 @@ describe Rack::Berater::Prioritizer do
113
126
  let(:env) { Rack::MockRequest.env_for('/') }
114
127
 
115
128
  it 'combines the verb and path' do
116
- is_expected.to match %r{get:/$}
129
+ is_expected.to match %r{GET:/$}
117
130
  end
118
131
  end
119
132
 
@@ -121,7 +134,7 @@ describe Rack::Berater::Prioritizer do
121
134
  let(:env) { Rack::MockRequest.env_for('/', method: 'PUT') }
122
135
 
123
136
  it 'combines the verb and path' do
124
- is_expected.to match %r{put:/$}
137
+ is_expected.to match %r{PUT:/$}
125
138
  end
126
139
  end
127
140
 
@@ -129,7 +142,7 @@ describe Rack::Berater::Prioritizer do
129
142
  let(:env) { Rack::MockRequest.env_for('/user/123') }
130
143
 
131
144
  it 'normalizes the id' do
132
- is_expected.to match %r{get:/user/x$}
145
+ is_expected.to match %r{GET:/user/x$}
133
146
  end
134
147
  end
135
148
 
@@ -137,7 +150,7 @@ describe Rack::Berater::Prioritizer do
137
150
  let(:env) { Rack::MockRequest.env_for('/user/123/') }
138
151
 
139
152
  it 'normalizes the id and keeps the trailing slash' do
140
- is_expected.to match %r{get:/user/x/$}
153
+ is_expected.to match %r{GET:/user/x/$}
141
154
  end
142
155
  end
143
156
 
@@ -145,7 +158,7 @@ describe Rack::Berater::Prioritizer do
145
158
  let(:env) { Rack::MockRequest.env_for('/user/123/friend/456') }
146
159
 
147
160
  it 'normalizes both ids' do
148
- is_expected.to match %r{get:/user/x/friend/x$}
161
+ is_expected.to match %r{GET:/user/x/friend/x$}
149
162
  end
150
163
  end
151
164
  end
@@ -157,7 +170,7 @@ describe Rack::Berater::Prioritizer do
157
170
 
158
171
  let(:app) do
159
172
  headers = {
160
- 'Content-Type' => 'text/plain',
173
+ 'content-type' => 'text/plain',
161
174
  described_class::HEADER => app_priority,
162
175
  }.compact
163
176
 
@@ -188,7 +201,6 @@ describe Rack::Berater::Prioritizer do
188
201
 
189
202
  it 'parses the priority returned from the app' do
190
203
  expect(call).to be_empty
191
- expect(cache.values).to include app_priority
192
204
  end
193
205
 
194
206
  it 'uses the cached priority for subsequent calls' do
@@ -196,18 +208,5 @@ describe Rack::Berater::Prioritizer do
196
208
  expect(call).to eq app_priority
197
209
  end
198
210
  end
199
-
200
- # context 'when two different endpoints are called' do
201
- # fit 'parses and caches each priority' do
202
- # @app_priority = '6'
203
- # expect(call('/six')).to be_empty
204
-
205
- # expect(call('/six')).to eq '6'
206
-
207
- # @app_priority = '9'
208
- # expect(call('/nine')).to be_empty
209
- # expect(call('/nine')).to '9'
210
- # end
211
- # end
212
211
  end
213
212
  end
@@ -1,5 +1,3 @@
1
- require 'rspec/rails'
2
-
3
1
  describe Rack::Berater::RailsPrioritizer do
4
2
  before do
5
3
  class EchoController < ActionController::Base
@@ -35,13 +33,6 @@ describe Rack::Berater::RailsPrioritizer do
35
33
  end
36
34
 
37
35
  let(:app) { Rails.application }
38
- let(:middleware) { described_class.new(app) }
39
-
40
- after do
41
- Rails.application = nil
42
- end
43
-
44
- let(:cache) { described_class.class_variable_get(:@@cache) }
45
36
 
46
37
  describe '#cache_key_for' do
47
38
  subject { described_class.new(app).method(:cache_key_for) }
@@ -63,13 +54,13 @@ describe Rack::Berater::RailsPrioritizer do
63
54
  it 'falls back to Rack style names' do
64
55
  expect(
65
56
  subject.call(Rack::MockRequest.env_for('/nine'))
66
- ).to match %r{get:/nine}
57
+ ).to match %r{GET:/nine}
67
58
  end
68
59
 
69
60
  it 'works with redirects' do
70
61
  expect(
71
62
  subject.call(Rack::MockRequest.env_for('/redirect'))
72
- ).to match %r{get:/redirect}
63
+ ).to match %r{GET:/redirect}
73
64
  end
74
65
  end
75
66
 
@@ -0,0 +1,39 @@
1
+ require 'rspec/rails'
2
+
3
+ describe Rack::Berater do
4
+ before do
5
+ class OkController < ActionController::Base
6
+ around_action :limit
7
+
8
+ def limit(&block)
9
+ Berater(:key, 1) { yield }
10
+ end
11
+
12
+ def index
13
+ head :ok
14
+ end
15
+ end
16
+
17
+ Rails.application = Class.new(Rails::Application) do
18
+ config.eager_load = false
19
+ config.hosts.clear # disable hostname filtering
20
+ # config.logger = ActiveSupport::Logger.new($stdout)
21
+ end
22
+ Rails.application.middleware.use described_class
23
+ Rails.initialize!
24
+
25
+ Rails.application.routes.draw do
26
+ get '/' => 'ok#index'
27
+ end
28
+ end
29
+
30
+ let(:app) { Rails.application }
31
+
32
+ it { expect(get('/')).to be_ok }
33
+
34
+ context 'when past limits' do
35
+ before { Berater.test_mode = :fail }
36
+
37
+ it { expect(get('/').status).to be 429 }
38
+ end
39
+ end
data/spec/rescuer_spec.rb CHANGED
@@ -3,8 +3,8 @@ describe Rack::Berater do
3
3
  Rack::Builder.new do
4
4
  use Rack::Lint
5
5
  run (lambda do |env|
6
- Berater::Unlimiter() do
7
- [200, {'Content-Type' => 'text/plain'}, ['OK']]
6
+ Berater(:key, 1) do
7
+ [200, {'content-type' => 'text/plain'}, ['OK']]
8
8
  end
9
9
  end)
10
10
  end
@@ -18,8 +18,8 @@ describe Rack::Berater do
18
18
 
19
19
  it 'has the correct headers' do
20
20
  expect(response.headers).to eq({
21
- 'Content-Type' => 'text/plain',
22
- 'Content-Length' => '2',
21
+ 'content-type' => 'text/plain',
22
+ 'content-length' => '2',
23
23
  })
24
24
  end
25
25
 
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-berater
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Pepper
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2021-11-18 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: berater
@@ -39,7 +38,7 @@ dependencies:
39
38
  - !ruby/object:Gem::Version
40
39
  version: '0'
41
40
  - !ruby/object:Gem::Dependency
42
- name: byebug
41
+ name: debug
43
42
  requirement: !ruby/object:Gem::Requirement
44
43
  requirements:
45
44
  - - ">="
@@ -53,7 +52,7 @@ dependencies:
53
52
  - !ruby/object:Gem::Version
54
53
  version: '0'
55
54
  - !ruby/object:Gem::Dependency
56
- name: codecov
55
+ name: graphql
57
56
  requirement: !ruby/object:Gem::Requirement
58
57
  requirements:
59
58
  - - ">="
@@ -86,14 +85,14 @@ dependencies:
86
85
  requirements:
87
86
  - - ">="
88
87
  - !ruby/object:Gem::Version
89
- version: '0'
88
+ version: '7'
90
89
  type: :development
91
90
  prerelease: false
92
91
  version_requirements: !ruby/object:Gem::Requirement
93
92
  requirements:
94
93
  - - ">="
95
94
  - !ruby/object:Gem::Version
96
- version: '0'
95
+ version: '7'
97
96
  - !ruby/object:Gem::Dependency
98
97
  name: rspec
99
98
  requirement: !ruby/object:Gem::Requirement
@@ -128,37 +127,38 @@ dependencies:
128
127
  requirements:
129
128
  - - ">="
130
129
  - !ruby/object:Gem::Version
131
- version: '0'
130
+ version: '1'
132
131
  type: :development
133
132
  prerelease: false
134
133
  version_requirements: !ruby/object:Gem::Requirement
135
134
  requirements:
136
135
  - - ">="
137
136
  - !ruby/object:Gem::Version
138
- version: '0'
137
+ version: '1'
139
138
  description: limit incoming requests
140
- email:
141
139
  executables: []
142
140
  extensions: []
143
141
  extra_rdoc_files: []
144
142
  files:
145
143
  - lib/rack-berater.rb
146
144
  - lib/rack/berater.rb
145
+ - lib/rack/berater/graphql_prioritizer.rb
147
146
  - lib/rack/berater/prioritizer.rb
148
147
  - lib/rack/berater/rails_prioritizer.rb
149
148
  - lib/rack/berater/railtie.rb
150
149
  - lib/rack/berater/rspec.rb
151
150
  - lib/rack/berater/version.rb
151
+ - spec/graphql_prioritizer_spec.rb
152
152
  - spec/limiter_spec.rb
153
153
  - spec/prioritizer_spec.rb
154
154
  - spec/rails_prioritizer_spec.rb
155
+ - spec/rails_spec.rb
155
156
  - spec/railtie_spec.rb
156
157
  - spec/rescuer_spec.rb
157
158
  homepage: https://github.com/dpep/rack-berater
158
159
  licenses:
159
160
  - MIT
160
161
  metadata: {}
161
- post_install_message:
162
162
  rdoc_options: []
163
163
  require_paths:
164
164
  - lib
@@ -166,20 +166,21 @@ required_ruby_version: !ruby/object:Gem::Requirement
166
166
  requirements:
167
167
  - - ">="
168
168
  - !ruby/object:Gem::Version
169
- version: '0'
169
+ version: '3.3'
170
170
  required_rubygems_version: !ruby/object:Gem::Requirement
171
171
  requirements:
172
172
  - - ">="
173
173
  - !ruby/object:Gem::Version
174
174
  version: '0'
175
175
  requirements: []
176
- rubygems_version: 3.1.6
177
- signing_key:
176
+ rubygems_version: 3.6.9
178
177
  specification_version: 4
179
178
  summary: Rack::Berater
180
179
  test_files:
180
+ - spec/graphql_prioritizer_spec.rb
181
+ - spec/limiter_spec.rb
182
+ - spec/prioritizer_spec.rb
181
183
  - spec/rails_prioritizer_spec.rb
184
+ - spec/rails_spec.rb
182
185
  - spec/railtie_spec.rb
183
186
  - spec/rescuer_spec.rb
184
- - spec/prioritizer_spec.rb
185
- - spec/limiter_spec.rb