Skip to content

Commit

Permalink
Cache api_keys (#93)
Browse files Browse the repository at this point in the history
* Cache api_keys

Currently have request to an internal service requires at least one
DB hit. For some services this is noise, for others it's the only
reason they have a databse. In a few cases, API key look up is
what the database is spending most of its time on.

Since this data is effectively immutable it's a great candidate for
caching. All apps would benefit from this.

This caches API keys in memory using a bounded LRU cache.

* Run Cache injector at define time

* Allow configuration via ENV

* Add new configuration values for api key cache

* migrate cache injector to a cache wrapper

* Use new cache key wrapper for api client wrapper

* Refactor api client access for configurable cache

* Update generators to include the new config values

* Test cache and db paths

* update docs

* Speling

* Disable clear when cache is disabled

* An RC

* More docs

* remove deprecated classes and upgrade mechanism (#94)

Co-authored-by: David Copeland <[email protected]>
  • Loading branch information
voidfiles and davetron5000 authored Jul 27, 2020
1 parent 7542917 commit f07a1af
Show file tree
Hide file tree
Showing 16 changed files with 247 additions and 110 deletions.
80 changes: 55 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ Create Microservices in Rails by pretty much just writing regular Rails code.

This gem provides:

* transparent API key authentication.
* router-level API version based on headers.
* a way to document your microservice endpoints via acceptance tests.
* structured errors, buildable from invalid Active Records, Exceptions, or by hand.
- transparent API key authentication.
- router-level API version based on headers.
- a way to document your microservice endpoints via acceptance tests.
- structured errors, buildable from invalid Active Records, Exceptions, or by hand.

This, plus much of what you get from Rails already, means you can create a microservice Rails application by just writing the
same Rails code you write today. Instead of rendering web views, you render JSON (which is built into Rails).
same Rails code you write today. Instead of rendering web views, you render JSON (which is built into Rails).

## To install

Expand All @@ -35,14 +35,26 @@ Then, set it up:

### Upgrading from an older version

* If you have a version lower than 3.3.0, you need to run two generators, one of which creates a new database migration on your
`api_clients` table:
- When upgrading to version 4.0.0 you may now take advantage of an in-memory cache

You can enabled it like so

```ruby
Stitches.configure do |config|
config.max_cache_ttl = 5 # seconds
config.max_cache_size = 100 # how many keys to cache
end
```

- If you have a version lower than 3.3.0, you need to run two generators, one of which creates a new database migration on your
`api_clients` table:

```
> bin/rails generate stitches:add_enabled_to_api_clients
> bin/rails generate stitches:add_deprecation
```
* If you have a version lower than 3.6.0, you need to run one generator:

- If you have a version lower than 3.6.0, you need to run one generator:

```
> bin/rails generate stitches:add_deprecation
Expand All @@ -59,8 +71,8 @@ class Api::V1::WidgetsController < ApiController
if widget.valid?
head 201
else
render json: {
errors: Stitches::Errors.from_active_record_object(widget)
render json: {
errors: Stitches::Errors.from_active_record_object(widget)
}, status: 422
end
end
Expand All @@ -73,44 +85,62 @@ private
end
```

If you think there's nothing special about this—you are correct. This is the vanillaest of vanilla Rails controllers, with a few
If you think there's nothing special about this—you are correct. This is the vanillaest of vanilla Rails controllers, with a few
notable exceptions:

* We aren't checking content type. A stitches-based microservice always uses JSON and refuses to route requests for non-JSON to
you, so there's zero need to use `respond_to` and friends.
* The error-building is structured and reliable.
* This is an authenticated request. No request without proper authentication will be routed here, so you don't have to worry
about it in your code.
* This is a versioned request. While the URL will *not* contain `v1` in it, the `Accept` header will require a version and get
routed here. If you make a V2, it's just a new controller and this concern is handled at the routing layer.
- We aren't checking content type. A stitches-based microservice always uses JSON and refuses to route requests for non-JSON to
you, so there's zero need to use `respond_to` and friends.
- The error-building is structured and reliable.
- This is an authenticated request. No request without proper authentication will be routed here, so you don't have to worry
about it in your code.
- This is a versioned request. While the URL will _not_ contain `v1` in it, the `Accept` header will require a version and get
routed here. If you make a V2, it's just a new controller and this concern is handled at the routing layer.

All this means that the Rails skills of you and your team can be directly applied to building microservices. You don't have to make a bunch of boring decisions about auth, versioning, or content-types. It also means you can start deploying and creating microservices with little friction. No need to deal with a complex DSL or new programming language to get yourselves going with Microservices.
All this means that the Rails skills of you and your team can be directly applied to building microservices. You don't have to make a bunch of boring decisions about auth, versioning, or content-types. It also means you can start deploying and creating microservices with little friction. No need to deal with a complex DSL or new programming language to get yourselves going with Microservices.

## More Info

See [the wiki](https://github.com/stitchfix/stitches/wiki/Setup) for how to setup stitches.

* [Stitches Features](https://github.com/stitchfix/stitches/wiki/Features-of-Stitches) include:
- [Stitches Features](https://github.com/stitchfix/stitches/wiki/Features-of-Stitches) include:
- Authorization via API key
- Versioned requests via HTTP content types
- Structured Errors
- ISO 8601-formatted dates
- Deprecation using the `Sunset` header
* The [Generator](https://github.com/stitchfix/stitches/wiki/Generator) sets up some code in your app, so you can start writing
APIs using vanilla Rails idioms:
- An optional ApiKey cache to allow mostly DB free APIs
- The [Generator](https://github.com/stitchfix/stitches/wiki/Generator) sets up some code in your app, so you can start writing
APIs using vanilla Rails idioms:
- a "ping" controller that can validate your app is working
- version routing based on content-type (requests for V2 use the same URL, but are serviced by a different controller)
- An ApiClient Active Record
- Acceptance tests that can produce API documentation as they test your app.
* Stitches provides [testing support](https://github.com/stitchfix/stitches/wiki/Testing)
- Stitches provides [testing support](https://github.com/stitchfix/stitches/wiki/Testing)

## API Key Caching

Since version 4.0.0, stitches now has the ability to cache API keys in
memory for a configurable amount of time. This may be an improvement for
some applications.

You must configure the API Cache for it be used.

```ruby
Stitches.configure do |config|
config.max_cache_ttl = 5 # seconds
config.max_cache_size = 100 # how many keys to cache
end
```

Your cache size should be
larger then the number of consumer keys your service has.

## Developing

Although `Stitches.configuration` is global, do not depend directly on that in your logic. Instead, allow all classes to receive a configuration object in their constructor. This makes the classes easier to deal with and change, without incurring much of a real cost to development. Global symbols suck, but are convenient. This is how you make the most of it.
Although `Stitches.configuration` is global, do not depend directly on that in your logic. Instead, allow all classes to receive a configuration object in their constructor. This makes the classes easier to deal with and change, without incurring much of a real cost to development. Global symbols suck, but are convenient. This is how you make the most of it.

Also, the integration test does a lot of "testing the implementation", but since Rails generators are notorious for silently
failing with a successful result, we have to make sure that the various `inject_into_file` calls are actually working. Do not do
failing with a successful result, we have to make sure that the various `inject_into_file` calls are actually working. Do not do
any fancy refactors here, just keep it up to date.

---
Expand Down
1 change: 0 additions & 1 deletion lib/stitches/allowlist_middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ module Stitches
# A middleware that will skip its behavior if the path matches an allowed URL
class AllowlistMiddleware
def initialize(app, options={})

@app = app
@configuration = options[:configuration] || Stitches.configuration
@except = options[:except] || @configuration.allowlist_regexp
Expand Down
42 changes: 42 additions & 0 deletions lib/stitches/api_client_access_wrapper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
require 'lru_redux'

module Stitches::ApiClientAccessWrapper

def self.fetch_for_key(key)
if cache_enabled
fetch_for_key_from_cache(key)
else
fetch_for_key_from_db(key)
end
end

def self.fetch_for_key_from_cache(key)
api_key_cache.getset(key) do
fetch_for_key_from_db(key)
end
end

def self.fetch_for_key_from_db(key)
if ::ApiClient.column_names.include?("enabled")
::ApiClient.find_by(key: key, enabled: true)
else
ActiveSupport::Deprecation.warn('api_keys is missing "enabled" column. Run "rails g stitches:add_enabled_to_api_clients"')
::ApiClient.find_by(key: key)
end
end

def self.clear_api_cache
api_key_cache.clear if cache_enabled
end

def self.api_key_cache
@api_key_cache ||= LruRedux::TTL::ThreadSafeCache.new(
Stitches.configuration.max_cache_size,
Stitches.configuration.max_cache_ttl,
)
end

def self.cache_enabled
Stitches.configuration.max_cache_ttl.positive?
end
end
9 changes: 1 addition & 8 deletions lib/stitches/api_key.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,7 @@ def do_call(env)
if authorization
if authorization =~ /#{@configuration.custom_http_auth_scheme}\s+key=(.*)\s*$/
key = $1

if ApiClient.column_names.include?("enabled")
client = ApiClient.where(key: key, enabled: true).first
else
ActiveSupport::Deprecation.warn('api_keys is missing "enabled" column. Run "rails g stitches:add_enabled_to_api_clients"')
client = ApiClient.where(key: key).first
end

client = Stitches::ApiClientAccessWrapper.fetch_for_key(key)
if client.present?
env[@configuration.env_var_to_hold_api_client_primary_key] = client.id
env[@configuration.env_var_to_hold_api_client] = client
Expand Down
41 changes: 35 additions & 6 deletions lib/stitches/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ def reset_to_defaults!
@custom_http_auth_scheme = UnsetString.new("custom_http_auth_scheme")
@env_var_to_hold_api_client_primary_key = NonNullString.new("env_var_to_hold_api_client_primary_key","STITCHES_API_CLIENT_ID")
@env_var_to_hold_api_client= NonNullString.new("env_var_to_hold_api_client","STITCHES_API_CLIENT")
@max_cache_ttl = NonNullInteger.new("max_cache_ttl", 0)
@max_cache_size = NonNullInteger.new("max_cache_size", 0)
end

# A RegExp that allows URLS around the mime type and api key requirements.
Expand All @@ -25,11 +27,6 @@ def allowlist_regexp=(new_allowlist_regexp)
@allowlist_regexp = new_allowlist_regexp
end

def whitelist_regexp=(new_allowlist_regexp)
self.allowlist_regexp = new_allowlist_regexp
warn("⚠️ 'whitelist' is deprecated in stitches configuration, please use 'allowlist' or auto-update with:\n\n bin/rails g stitches:update_configuration\n\n⚠️ 'whitelist' will be removed in 4.0")
end

# The name of your custom http auth scheme. This must be set, and has no default
def custom_http_auth_scheme
@custom_http_auth_scheme.to_s
Expand All @@ -39,7 +36,7 @@ def custom_http_auth_scheme=(new_custom_http_auth_scheme)
@custom_http_auth_scheme = NonNullString.new("custom_http_auth_scheme",new_custom_http_auth_scheme)
end

# The name of the environment variable that the ApiKey middleware should use to
# The name of the environment variable that the ApiKey middleware should use to
# place the primary key of the authenticated ApiKey. For example, if a user provides
# the api key 1234-1234-1234-1234, and that maps to the primary key 42 in your database,
# the environment will contain "42" in the key provided here.
Expand All @@ -59,8 +56,40 @@ def env_var_to_hold_api_client=(new_env_var_to_hold_api_client)
@env_var_to_hold_api_client= NonNullString.new("env_var_to_hold_api_client",new_env_var_to_hold_api_client)
end

def max_cache_ttl
@max_cache_ttl.to_i
end

def max_cache_ttl=(new_max_cache_ttl)
@max_cache_ttl = NonNullInteger.new("max_cache_ttl", new_max_cache_ttl)
end

def max_cache_size
@max_cache_size.to_i
end

def max_cache_size=(new_max_cache_size)
@max_cache_size = NonNullInteger.new("max_cache_size", new_max_cache_size)
end

private

class NonNullInteger
def initialize(name, value)
unless value.is_a?(Integer)
raise "#{name} must be an Integer, not a #{value.class}"
end

@value = value
end

def to_i
@value
end

alias to_integer to_i
end

class NonNullString
def initialize(name,string)
unless string.nil? || string.is_a?(String)
Expand Down
10 changes: 10 additions & 0 deletions lib/stitches/generator_files/config/initializers/stitches.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,14 @@
# Env var that gets the primary key of the authenticated ApiKey
# for access in your controllers, so they don't need to re-parse the header
# configuration.env_var_to_hold_api_client_primary_key = "YOUR_ENV_VAR"

# Configures how long to cache ApiKeys in memory (In Seconds)
# A value of 0 will disable the cache entierly
# Default is 0
# configuration.max_cache_ttl = 5

# Configures how many ApiKeys to cache at one time
# This should be larger then the number of clients
# Default is 0
# configuration.max_cache_size = 100
end
2 changes: 2 additions & 0 deletions lib/stitches/railtie.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
require 'stitches/api_key'
require 'stitches/valid_mime_type'
require 'stitches/api_client_access_wrapper'

module Stitches
class Railtie < Rails::Railtie
config.app_middleware.use Stitches::ApiKey
config.app_middleware.use Stitches::ValidMimeType

end
end
16 changes: 0 additions & 16 deletions lib/stitches/update_configuration_generator.rb

This file was deleted.

4 changes: 3 additions & 1 deletion lib/stitches/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# frozen_string_literal: true

module Stitches
VERSION = "3.8.3"
VERSION = '4.0.0'
end
5 changes: 0 additions & 5 deletions lib/stitches/whitelisting_middleware.rb

This file was deleted.

1 change: 0 additions & 1 deletion lib/stitches_norailtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ def self.configuration
require 'stitches/api_generator'
require 'stitches/add_deprecation_generator'
require 'stitches/add_enabled_to_api_clients_generator'
require 'stitches/update_configuration_generator'
require 'stitches/api_version_constraint'
require 'stitches/api_key'
require 'stitches/deprecation'
Expand Down
52 changes: 52 additions & 0 deletions spec/api_client_access_wrapper_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
require 'spec_helper.rb'

module MyApp
class Application
end
end

unless defined? ApiClient
class ApiClient
def self.column_names
["enabled"]
end
end
end

describe Stitches::ApiClientAccessWrapper do
let(:api_client) {
double(ApiClient, id: 42)
}
before do
Stitches.configuration.reset_to_defaults!
end
describe '#fetch_by_key' do
context "cache is disabled" do
before do
expect(ApiClient).to receive(:find_by).and_return(api_client).twice
end

it "fetchs object from db twice" do
expect(described_class.fetch_for_key("123").id).to eq(42)
expect(described_class.fetch_for_key("123").id).to eq(42)
end
end

context "cache is configured" do
before do
Stitches.configure do |config|
config.max_cache_ttl = 5
config.max_cache_size = 10
end

expect(ApiClient).to receive(:find_by).and_return(api_client).once
end

it "fetchs object from cache" do
expect(described_class.fetch_for_key("123").id).to eq(42)
# This should hit the cache
expect(described_class.fetch_for_key("123").id).to eq(42)
end
end
end
end
Loading

0 comments on commit f07a1af

Please sign in to comment.