diff --git a/CHANGELOG.md b/CHANGELOG.md index e63dbe10c..55d7cf5a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed 3scale Batcher policy unable to handle `app_id`/`access_token` contains special characters [PR #1457](https://github.com/3scale/APIcast/pull/1457) [THREESCALE-10934](https://issues.redhat.com/browse/THREESCALE-10934) +### Added + +- Introduce `transaction_id` policy [PR #1460](https://github.com/3scale/APIcast/pull/1460) [THREESCALE-10973](https://issues.redhat.com/browse/THREESCALE-10973) + ## [3.15.0] 2024-04-04 ### Fixed diff --git a/gateway/src/apicast/policy/transaction_id/README.md b/gateway/src/apicast/policy/transaction_id/README.md new file mode 100644 index 000000000..dda7697d7 --- /dev/null +++ b/gateway/src/apicast/policy/transaction_id/README.md @@ -0,0 +1,65 @@ +# APICast Transaction-ID + +## Description + +When enabled this policy adds a new header with unique ID to all of the request processed by APIcast. The unique ID header can also be included in the response to the client. + +If the header is empty or non-existent, this policy will generate a UUID as the value of the user-defined header name + +If a header with the same name is already present in the client request or upstream response, the policy will not modify it. + +## Example configuration + +* Add request unique ID +``` +"policy_chain": [ + { + "name": "transaction_id", + "configuration": { + "header_name": "X-Transaction-ID" + }, + "version": "builtin", + }, + { + "name": "apicast.policy.apicast" + } +] +``` + +* Include the header in response +``` +"policy_chain": [ + { + "name": "transaction_id", + "configuration": { + "header_name": "X-Transaction-ID", + "include_in_response": true + }, + "version": "builtin", + }, + { + "name": "apicast.policy.apicast" + } +] +``` + +* Use with Logging policy + +``` +"policy_chain": [ + { + "name": "transaction_id", + "configuration": { + "header_name": "X-Transaction-ID" + }, + "version": "builtin", + }, + { + "name": "apicast.policy.logging", + "configuration": { + "enable_access_logs": false, + "custom_logging": "\"{{request}}\" to service {{service.id}} and {{service.name}} with ID {{req.headers.X-Transaction-ID}}", + } + } +] +``` diff --git a/gateway/src/apicast/policy/transaction_id/apicast-policy.json b/gateway/src/apicast/policy/transaction_id/apicast-policy.json new file mode 100644 index 000000000..56ba32009 --- /dev/null +++ b/gateway/src/apicast/policy/transaction_id/apicast-policy.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://apicast.io/policy-v1/schema#manifest#", + "name": "Transaction ID", + "summary": "Add unique ID to the request/response header", + "description": ["This policy adds a unique ID to each request and response header proxied through APIcast", + "The policy will not add a unique ID if the request/response already has a header with the configured header_name." + ], + "version": "builtin", + "configuration": { + "type": "object", + "properties": { + "header_name": { + "type": "string", + "description": "The HTTP header name to use for the transaction IDs", + "default": "X-Transaction-ID" + }, + "include_in_response": { + "type": "boolean", + "description": "Whether to include the header to the response", + "default": false + } + } + } +} diff --git a/gateway/src/apicast/policy/transaction_id/init.lua b/gateway/src/apicast/policy/transaction_id/init.lua new file mode 100644 index 000000000..80700fe47 --- /dev/null +++ b/gateway/src/apicast/policy/transaction_id/init.lua @@ -0,0 +1 @@ +return require('transaction_id') diff --git a/gateway/src/apicast/policy/transaction_id/transaction_id.lua b/gateway/src/apicast/policy/transaction_id/transaction_id.lua new file mode 100644 index 000000000..0048ac831 --- /dev/null +++ b/gateway/src/apicast/policy/transaction_id/transaction_id.lua @@ -0,0 +1,46 @@ +--- Transaction ID policy +-- This policy add a uniqud ID to the user defined header. This can help to identify +-- request from access log or the trace + +local policy = require('apicast.policy') +local _M = policy.new('Transaction ID', 'builtin') + +local uuid = require 'resty.jit-uuid' + +local new = _M.new + +function _M.new(config) + local self = new(config) + local conf = config or {} + self.header_name = conf.header_name + self.include_in_response = conf.include_in_response or false + + return self +end + +function _M:rewrite(context) + local transaction_id = ngx.req.get_headers()[self.header_name] + + if not transaction_id or transaction_id == "" then + transaction_id = uuid.generate_v4() + ngx.req.set_header(self.header_name, transaction_id) + end + + if self.include_in_response then + context.transaction_id = transaction_id + end +end + +function _M:header_filter(context) + if not self.include_in_response then + return + end + + local transaction_id = ngx.resp.get_headers()[self.header_name] + if not transaction_id or transaction_id == "" then + transaction_id = context.transaction_id + end + ngx.header[self.header_name] = transaction_id +end + +return _M diff --git a/spec/policy/transaction_id/transaction_id_spec.lua b/spec/policy/transaction_id/transaction_id_spec.lua new file mode 100644 index 000000000..a10b8bfee --- /dev/null +++ b/spec/policy/transaction_id/transaction_id_spec.lua @@ -0,0 +1,80 @@ +local TransactionIDPolicy = require('apicast.policy.transaction_id') +local uuid = require('resty.jit-uuid') + +describe('fapi_1_baseline_profile policy', function() + local ngx_req_headers = {} + local ngx_resp_headers = {} + local context = {} + before_each(function() + ngx.header = {} + ngx_req_headers = {} + ngx_resp_headers = {} + context = {} + stub(ngx.req, 'get_headers', function() return ngx_req_headers end) + stub(ngx.req, 'set_header', function(name, value) ngx_req_headers[name] = value end) + stub(ngx.resp, 'get_headers', function() return ngx_resp_headers end) + stub(ngx.resp, 'set_header', function(name, value) ngx_resp_headers[name] = value end) + end) + + describe('.new', function() + it('works without configuration', function() + assert(TransactionIDPolicy.new()) + end) + end) + + describe('.rewrite', function() + it('do not overwrite existing header', function() + ngx_req_headers['transaction-id'] = 'abc' + local config = {header_name='transaction-id'} + local transaction_id_policy = TransactionIDPolicy.new(config) + transaction_id_policy:rewrite() + assert.same('abc', ngx.req.get_headers()['transaction-id']) + end) + + it('generate uuid if header does not exist', function() + local config = {header_name='transaction-id'} + local transaction_id_policy = TransactionIDPolicy.new(config) + transaction_id_policy:rewrite() + assert.is_true(uuid.is_valid(ngx.req.get_headers()['transaction-id'])) + end) + + it('generate uuid if header is empty', function() + ngx_req_headers['transaction-id'] = '' + local config = {header_name='transaction-id'} + local transaction_id_policy = TransactionIDPolicy.new(config) + transaction_id_policy:rewrite() + assert.is_true(uuid.is_valid(ngx.req.get_headers()['transaction-id'])) + end) + end) + + describe('.header_filter', function() + it('set response transaction-id if configured', function() + ngx_req_headers['transaction-id'] = 'abc' + local config = {header_name='transaction-id', include_in_response=true} + local transaction_id_policy = TransactionIDPolicy.new(config) + transaction_id_policy:rewrite(context) + transaction_id_policy:header_filter(context) + assert.same('abc', ngx.header['transaction-id']) + end) + + it('set response transaction-id if configured - uuid', function() + ngx_req_headers['transaction-id'] = '' + local config = {header_name='transaction-id', include_in_response=true} + local transaction_id_policy = TransactionIDPolicy.new(config) + transaction_id_policy:rewrite(context) + local id = ngx.req.get_headers()['transaction-id'] + transaction_id_policy:header_filter(context) + assert.same(id, ngx.header['transaction-id']) + end) + + it('do not override if response contain the exisitng header', function() + ngx_req_headers['transaction-id'] = 'abc' + ngx_resp_headers['transaction-id'] = 'edf' + local config = {header_name='transaction-id', include_in_response=true} + local transaction_id_policy = TransactionIDPolicy.new(config) + transaction_id_policy:rewrite(context) + transaction_id_policy:header_filter(context) + assert.same('edf', ngx.header['transaction-id']) + end) + end) +end) diff --git a/t/apicast-policy-transaction-id.t b/t/apicast-policy-transaction-id.t new file mode 100644 index 000000000..1fed4fa5c --- /dev/null +++ b/t/apicast-policy-transaction-id.t @@ -0,0 +1,285 @@ +use lib 't'; +use Test::APIcast::Blackbox 'no_plan'; + +# Test::Nginx does not allow to grep access logs, so we redirect them to +# stderr to be able to use "grep_error_log" by setting APICAST_ACCESS_LOG_FILE +$ENV{APICAST_ACCESS_LOG_FILE} = "$Test::Nginx::Util::ErrLogFile"; + +run_tests(); + +__DATA__ + +=== TEST 1: Enables transaction-id policy and generate uuid if the header is not +present in the client request +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.transaction_id", + "configuration": { + "header_name": "X-Transaction-ID" + } + }, + { + "name": "apicast.policy.apicast" + } + ] + } + } + ] +} + +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } +--- upstream + location / { + content_by_lua_block { + local assert = require('luassert') + local uuid = require('resty.jit-uuid') + assert.is_true(uuid.is_valid(ngx.req.get_headers()['X-Transaction-ID'])) + } + } +--- request +GET /?user_key=value +--- error_code: 200 +--- no_error_log +[error] + + + +=== TEST 2: Respect header from the original request +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.transaction_id", + "configuration": { + "header_name": "x-transaction-id" + } + }, + { + "name": "apicast.policy.apicast" + } + ] + } + } + ] +} + +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } +--- upstream + location / { + content_by_lua_block { + local assert = require('luassert') + local id = ngx.req.get_headers()['X-Transaction-ID'] + assert.equal('blah', id) + } + } +--- more_headers +X-Transaction-ID: blah +--- request +GET /?user_key=value +--- error_code: 200 +--- no_error_log +[error] + + + +=== TEST 3: Also set the response header +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.transaction_id", + "configuration": { + "header_name": "x-transaction-id", + "include_in_response": true + } + }, + { + "name": "apicast.policy.apicast" + } + ] + } + } + ] +} + +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } +--- upstream + location / { + content_by_lua_block { + local assert = require('luassert') + local id = ngx.req.get_headers()['X-Transaction-ID'] + assert.equal('blah', id) + } + } +--- more_headers +X-Transaction-ID: blah +--- request +GET /?user_key=value +--- response_headers +X-Transaction-ID: blah +--- error_code: 200 +--- no_error_log +[error] + + + +=== TEST 4: respect the response header from upstream +--- configuration +{ + "services": [ + { + "id": 42, + "backend_version": 1, + "backend_authentication_type": "service_token", + "backend_authentication_value": "token-value", + "proxy": { + "api_backend": "http://test:$TEST_NGINX_SERVER_PORT/", + "proxy_rules": [ + { "pattern": "/", "http_method": "GET", "metric_system_name": "hits", "delta": 1 } + ], + "policy_chain": [ + { + "name": "apicast.policy.transaction_id", + "configuration": { + "header_name": "x-transaction-id", + "include_in_response": true + } + }, + { + "name": "apicast.policy.apicast" + } + ] + } + } + ] +} + +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } +--- upstream + location / { + content_by_lua_block { + local assert = require('luassert') + ngx.header['X-Transaction-ID'] = "foo" + } + } +--- more_headers +X-Transaction-ID: blah +--- request +GET /?user_key=value +--- response_headers +X-Transaction-ID: foo +--- error_code: 200 +--- no_error_log +[error] + + + +=== TEST 5: work with Logging policy and custom access log format +--- configuration +{ + "services": [ + { + "id": 42, + "proxy": { + "policy_chain": [ + { + "name": "apicast.policy.transaction_id", + "configuration": { + "header_name": "x-transaction-id", + "include_in_response": true + } + }, + { + "name": "apicast.policy.logging", + "configuration": { + "custom_logging": "Status::{{ status }} Request-Transaction-ID::{{ req.headers.X-Transaction-ID }} Response-Transaction-ID::{{ resp.headers.X-Transaction-ID }}" + } + }, + { + "name": "apicast.policy.upstream", + "configuration": + { + "rules": [ { "regex": "/", "url": "http://echo" } ] + } + } + ] + } + } + ] +} +--- backend + location /transactions/authrep.xml { + content_by_lua_block { + ngx.exit(200) + } + } +--- upstream + location / { + content_by_lua_block { + ngx.say("yay") + } + } +--- more_headers +X-Transaction-ID: blah +--- request +GET /?user_key=value +--- error_code: 200 +--- error_log eval +[ qr/^Status\:\:200 Request-Transaction-ID\:\:blah Response-Transaction-ID\:\:blah/ ] +--- no_error_log eval +[qr/\[error/, qr/GET \/ HTTP\/1.1\" 200/]