blob: 810859809c983907d40258d805ec25d3d53d237e [file] [log] [blame]
-- Copyright (c) 2016 IBM. All rights reserved.
--
-- Permission is hereby granted, free of charge, to any person obtaining a
-- copy of this software and associated documentation files (the "Software"),
-- to deal in the Software without restriction, including without limitation
-- the rights to use, copy, modify, merge, publish, distribute, sublicense,
-- and/or sell copies of the Software, and to permit persons to whom the
-- Software is furnished to do so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in
-- all copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-- DEALINGS IN THE SOFTWARE.
--- @module management
-- Defines and exposes a lightweight API management to create and remove resources in the running API Gateway
-- @author Alex Song (songs)
local cjson = require "cjson"
local redis = require "lib/redis"
local utils = require "lib/utils"
local logger = require "lib/logger"
local request = require "lib/request"
local MANAGEDURL_HOST = os.getenv("PUBLIC_MANAGEDURL_HOST")
MANAGEDURL_HOST = (MANAGEDURL_HOST ~= nil and MANAGEDURL_HOST ~= '') and MANAGEDURL_HOST or "0.0.0.0"
local MANAGEDURL_PORT = os.getenv("PUBLIC_MANAGEDURL_PORT")
MANAGEDURL_PORT = (MANAGEDURL_PORT ~= nil and MANAGEDURL_PORT ~= '') and MANAGEDURL_PORT or "8080"
local REDIS_HOST = os.getenv("REDIS_HOST")
local REDIS_PORT = os.getenv("REDIS_PORT")
local REDIS_PASS = os.getenv("REDIS_PASS")
local REDIS_FIELD = "resources"
local BASE_CONF_DIR = "/etc/api-gateway/managed_confs/"
local _M = {}
--------------------------
---------- APIs ----------
--------------------------
--- Add an api to the Gateway
-- PUT /v1/apis
-- body:
-- {
-- "name": *(String) name of API
-- "basePath": *(String) base path for api
-- "tenantId": *(String) tenant id
-- "resources": *(String) resources to add
-- }
function _M.addAPI()
-- Open connection to redis or use one from connection pool
local red = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 10000)
-- Check for api id from uri and use existingAPI if it already exists in redis
local uri = string.gsub(ngx.var.request_uri, "?.*", "")
local existingAPI = checkURIForExisting(red, uri, "api")
-- Read in the PUT JSON Body
ngx.req.read_body()
local args = ngx.req.get_body_data()
if not args then
request.err(400, "Missing request body")
end
-- Convert json into Lua table
local decoded = cjson.decode(args)
-- Check for api id in JSON body
if existingAPI == nil and decoded.id ~= nil then
existingAPI = redis.getAPI(red, decoded.id)
if existingAPI == nil then
request.err(404, utils.concatStrings({"Unknown API id ", decoded.id}))
end
end
-- Error checking
local fields = {"name", "basePath", "tenantId", "resources"}
for k, v in pairs(fields) do
local res, err = isValid(red, v, decoded[v])
if res == false then
request.err(err.statusCode, err.message)
end
end
-- Format basePath
local basePath = decoded.basePath:sub(1,1) == '/' and decoded.basePath:sub(2) or decoded.basePath
basePath = basePath:sub(-1) == '/' and basePath:sub(1, -2) or basePath
-- Create managedUrl object
local uuid = existingAPI ~= nil and existingAPI.id or utils.uuid()
local managedUrl = utils.concatStrings({"http://", MANAGEDURL_HOST, ":", MANAGEDURL_PORT, "/api/", decoded.tenantId})
if basePath:sub(1,1) ~= '' then
managedUrl = utils.concatStrings({managedUrl, "/", basePath})
end
local managedUrlObj = {
id = uuid,
name = decoded.name,
basePath = utils.concatStrings({"/", basePath}),
tenantId = decoded.tenantId,
resources = decoded.resources,
managedUrl = managedUrl
}
-- Add API object to redis
managedUrlObj = redis.addAPI(red, uuid, managedUrlObj, existingAPI)
-- Add resources to redis
for path, resource in pairs(decoded.resources) do
local gatewayPath = utils.concatStrings({basePath, path})
gatewayPath = (gatewayPath:sub(1,1) == '/') and gatewayPath:sub(2) or gatewayPath
addResource(red, resource, gatewayPath, decoded.tenantId)
end
redis.close(red)
-- Return managed url object
ngx.header.content_type = "application/json; charset=utf-8"
request.success(200, managedUrlObj)
end
--- Check JSON body fields for errors
-- @param red Redis client instance
-- @param field name of field
-- @param object field object
function isValid(red, field, object)
-- Check that field exists in body
if not object then
return false, { statusCode = 400, message = utils.concatStrings({"Missing field '", field, "' in request body."}) }
end
-- Additional check for basePath
if field == "basePath" then
local basePath = object
if string.match(basePath, "'") then
return false, { statusCode = 400, message = "basePath contains illegal character \"'\"." }
end
end
-- Additional check for tenantId
if field == "tenantId" then
local tenant = redis.getTenant(red, object)
if tenant == nil then
return false, { statusCode = 404, message = utils.concatStrings({"Unknown tenant id ", object }) }
end
end
if field == "resources" then
local res, err = checkResources(object)
if res ~= nil and res == false then
return res, err
end
end
-- All error checks passed
return true
end
--- Error checking for resources
-- @param resources resources object
function checkResources(resources)
if next(resources) == nil then
return false, { statusCode = 400, message = "Empty resources object." }
end
for path, resource in pairs(resources) do
-- Check resource path for illegal characters
if string.match(path, "'") then
return false, { statusCode = 400, message = "resource path contains illegal character \"'\"." }
end
-- Check that resource path begins with slash
if path:sub(1,1) ~= '/' then
return false, { statusCode = 400, message = "Resource path must begin with '/'." }
end
-- Check operations object
local res, err = checkOperations(resource.operations)
if res ~= nil and res == false then
return res, err
end
end
end
--- Error checking for operations
-- @param operations operations object
function checkOperations(operations)
if not operations or next(operations) == nil then
return false, { statusCode = 400, message = "Missing or empty field 'operations' or in resource path object." }
end
local allowedVerbs = {GET=true, POST=true, PUT=true, DELETE=true, PATCH=true, HEAD=true, OPTIONS=true}
for verb, verbObj in pairs(operations) do
if allowedVerbs[verb:upper()] == nil then
return false, { statusCode = 400, message = utils.concatStrings({"Resource verb '", verb, "' not supported."}) }
end
-- Check required fields
local requiredFields = {"backendMethod", "backendUrl"}
for k, v in pairs(requiredFields) do
if verbObj[v] == nil then
return false, { statusCode = 400, message = utils.concatStrings({"Missing field '", v, "' for '", verb, "' operation."}) }
end
if v == "backendMethod" then
local backendMethod = verbObj[v]
if allowedVerbs[backendMethod:upper()] == nil then
return false, { statusCode = 400, message = utils.concatStrings({"backendMethod '", backendMethod, "' not supported."}) }
end
end
end
-- Check optional fields
local res, err = checkOptionalPolicies(verbObj.policies, verbObj.security)
if res ~= nil and res == false then
return res, err
end
end
end
--- Error checking for policies and security
-- @param policies policies object
-- @param security security object
function checkOptionalPolicies(policies, security)
if policies then
for k, v in pairs(policies) do
local validTypes = {reqMapping = true, rateLimit = true}
if (v.type == nil or v.value == nil) then
return false, { statusCode = 400, message = "Missing field in policy object. Need \"type\" and \"scope\"." }
elseif validTypes[v.type] == nil then
return false, { statusCode = 400, message = "Invalid type in policy object. Valid: \"reqMapping\", \"rateLimit\"" }
end
end
end
if security then
local validScopes = {tenant=true, api=true, resource=true}
if (security.type == nil or security.scope == nil) then
return false, { statusCode = 400, message = "Missing field in security object. Need \"type\" and \"scope\"." }
elseif validScopes[security.scope] == nil then
return false, { statusCode = 400, message = "Invalid scope in security object. Valid: \"tenant\", \"api\", \"resource\"." }
end
end
end
--- Helper function for adding a resource to redis and creating an nginx conf file
-- @param red
-- @param resource
-- @param gatewayPath
-- @param tenantId
function addResource(red, resource, gatewayPath, tenantId)
-- Create resource object and add to redis
local redisKey = utils.concatStrings({"resources", ":", tenantId, ":", gatewayPath})
local apiId
local operations
for k, v in pairs(resource) do
if k == 'apiId' then
apiId = v
elseif k == 'operations' then
operations = v
end
end
local resourceObj = redis.generateResourceObj(operations, apiId)
redis.createResource(red, redisKey, REDIS_FIELD, resourceObj)
end
--- Get one or all APIs from the gateway
-- GET /v1/apis
function _M.getAPIs()
local uri = string.gsub(ngx.var.request_uri, "?.*", "")
local id
local index = 1
local tenantQuery = false
for word in string.gmatch(uri, '([^/]+)') do
if index == 3 then
id = word
elseif index == 4 then
if word == 'tenant' then
tenantQuery = true
else
request.err(400, "Invalid request")
end
end
index = index + 1
end
if id == nil then
getAllAPIs()
else
if tenantQuery == false then
getAPI(id)
else
getAPITenant(id)
end
end
end
--- Get all APIs in redis
function getAllAPIs()
local red = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 10000)
local res = redis.getAllAPIs(red)
redis.close(red)
local apiList = {}
for k, v in pairs(res) do
if k%2 == 0 then
apiList[#apiList+1] = cjson.decode(v)
end
end
apiList = cjson.encode(apiList)
ngx.header.content_type = "application/json; charset=utf-8"
request.success(200, apiList)
end
--- Get API by its id
-- @param id of API
function getAPI(id)
local red = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 10000)
local api = redis.getAPI(red, id)
if api == nil then
request.err(404, utils.concatStrings({"Unknown api id ", id}))
end
redis.close(red)
ngx.header.content_type = "application/json; charset=utf-8"
request.success(200, cjson.encode(api))
end
--- Get belongsTo relation tenant
-- @param id id of API
function getAPITenant(id)
local red = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 10000)
local api = redis.getAPI(red, id)
if api == nil then
request.err(404, utils.concatStrings({"Unknown api id ", id}))
end
local tenantId = api.tenantId
local tenant = redis.getTenant(red, tenantId)
if tenant == nil then
request.err(404, utils.concatStrings({"Unknown tenant id ", tenantId}))
end
redis.close(red)
ngx.header.content_type = "application/json; charset=utf-8"
request.success(200, cjson.encode(tenant))
end
--- Delete API from gateway
-- DELETE /v1/apis/<id>
function _M.deleteAPI()
local uri = string.gsub(ngx.var.request_uri, "?.*", "")
local index = 1
local id
for word in string.gmatch(uri, '([^/]+)') do
if index == 3 then
id = word
end
index = index + 1
end
if id == nil then
request.err(400, "No id specified.")
end
local red = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 10000)
local api = redis.getAPI(red, id)
if api == nil then
request.err(404, utils.concatStrings({"Unknown API id ", id}))
end
-- Delete API
redis.deleteAPI(red, id)
-- Delete all resources for the API
local basePath = api.basePath:sub(2)
for path, v in pairs(api.resources) do
local gatewayPath = utils.concatStrings({basePath, path})
gatewayPath = (gatewayPath:sub(1,1) == '/') and gatewayPath:sub(2) or gatewayPath
deleteResource(red, gatewayPath, api.tenantId)
end
redis.close(red)
request.success(200, {})
end
--- Helper function for deleting resource in redis and appropriate conf files
-- @param red redis instance
-- @param gatewayPath path in gateway
-- @param tenantId tenant id
function deleteResource(red, gatewayPath, tenantId)
local redisKey = utils.concatStrings({"resources:", tenantId, ":", gatewayPath})
redis.deleteResource(red, redisKey, REDIS_FIELD)
end
-----------------------------
---------- Tenants ----------
-----------------------------
--- Add a tenant to the Gateway
-- PUT /v1/tenants
-- body:
-- {
-- "namespace": *(String) tenant namespace
-- "instance": *(String) tenant instance
-- }
function _M.addTenant()
-- Open connection to redis or use one from connection pool
local red = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 10000)
-- Check for tenant id and use existingTenant if it already exists in redis
local uri = string.gsub(ngx.var.request_uri, "?.*", "")
local existingTenant = checkURIForExisting(red, uri, "tenant")
-- Read in the PUT JSON Body
ngx.req.read_body()
local args = ngx.req.get_body_data()
if not args then
request.err(400, "Missing request body")
end
-- Convert json into Lua table
local decoded = cjson.decode(args)
-- Check for tenant id in JSON body
if existingTenant == nil and decoded.id ~= nil then
existingTenant = redis.getTenant(red, decoded.id)
if existingTenant == nil then
request.err(404, utils.concatStrings({"Unknown Tenant id ", decoded.id}))
end
end
-- Error checking
local fields = {"namespace", "instance"}
for k, v in pairs(fields) do
if not decoded[v] then
request.err(400, utils.concatStrings({"Missing field '", v, "' in request body."}))
end
end
-- Return tenant object
local uuid = existingTenant ~= nil and existingTenant.id or utils.uuid()
local tenantObj = {
id = uuid,
namespace = decoded.namespace,
instance = decoded.instance
}
tenantObj = redis.addTenant(red, uuid, tenantObj)
redis.close(red)
ngx.header.content_type = "application/json; charset=utf-8"
request.success(200, tenantObj)
end
--- Get one or all tenants from the gateway
-- GET /v1/tenants
function _M.getTenants()
local uri = string.gsub(ngx.var.request_uri, "?.*", "")
local id
local index = 1
local apiQuery = false
for word in string.gmatch(uri, '([^/]+)') do
if index == 3 then
id = word
elseif index == 4 then
if word:lower() == 'apis' then
apiQuery = true
else
request.err(400, "Invalid request")
end
end
index = index + 1
end
if id == nil then
getAllTenants()
else
if apiQuery == false then
getTenant(id)
else
getTenantAPIs(id)
end
end
end
--- Get all tenants in redis
function getAllTenants()
local red = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 10000)
local res = redis.getAllTenants(red)
redis.close(red)
local tenantList = {}
for k, v in pairs(res) do
if k%2 == 0 then
tenantList[#tenantList+1] = cjson.decode(v)
end
end
tenantList = cjson.encode(tenantList)
ngx.header.content_type = "application/json; charset=utf-8"
request.success(200, tenantList)
end
--- Get tenant by its id
-- @param id tenant id
function getTenant(id)
local red = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 10000)
local tenant = redis.getTenant(red, id)
if tenant == nil then
request.err(404, utils.concatStrings({"Unknown tenant id ", id }))
end
redis.close(red)
ngx.header.content_type = "application/json; charset=utf-8"
request.success(200, cjson.encode(tenant))
end
--- Get APIs associated with tenant
-- @param id tenant id
function getTenantAPIs(id)
local red = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 10000)
local res = redis.getAllAPIs(red)
redis.close(red)
local apiList = {}
for k, v in pairs(res) do
if k%2 == 0 then
local decoded = cjson.decode(v)
if decoded.tenantId == id then
apiList[#apiList+1] = decoded
end
end
end
apiList = cjson.encode(apiList)
ngx.header.content_type = "application/json; charset=utf-8"
request.success(200, apiList)
end
--- Delete tenant from gateway
-- DELETE /v1/tenants/<id>
function _M.deleteTenant()
local uri = string.gsub(ngx.var.request_uri, "?.*", "")
local index = 1
local id
for word in string.gmatch(uri, '([^/]+)') do
if index == 3 then
id = word
end
index = index + 1
end
if id == nil then
request.err(400, "No id specified.")
end
local red = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 1000)
redis.deleteTenant(red, id)
redis.close(red)
request.success(200, {})
end
------------------------------
----- Pub/Sub with Redis -----
------------------------------
--- Sync with redis
-- GET /v1/sync
function _M.sync()
local red = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 10000)
redis.syncWithRedis(red)
ngx.exit(200)
end
--- Subscribe to redis
-- GET /v1/subscribe
function _M.subscribe()
local redisGetClient = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 10000)
local redisSubClient = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 1000)
logger.info(utils.concatStrings({"Connected to redis at ", REDIS_HOST, ":", REDIS_PORT}))
redis.subscribe(redisSubClient, redisGetClient)
ngx.exit(200)
end
--- Get gateway sync status
function _M.healthCheck()
redis.healthCheck()
end
---------------------------
------ Subscriptions ------
---------------------------
--- Add an apikey/subscription to redis
-- PUT /subscriptions
-- Body:
-- {
-- key: *(String) key for tenant/api/resource
-- scope: *(String) tenant or api or resource
-- tenantId: *(String) tenant id
-- resource: (String) url-encoded resource path
-- apiId: (String) api id
-- }
function _M.addSubscription()
-- Validate body and create redisKey
local redisKey = validateSubscriptionBody()
-- Open connection to redis or use one from connection pool
local red = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 10000)
redis.createSubscription(red, redisKey)
-- Add current redis connection in the ngx_lua cosocket connection pool
redis.close(red)
request.success(200, "Subscription created.")
end
--- Delete apikey/subscription from redis
-- DELETE /subscriptions
-- Body:
-- {
-- key: *(String) key for tenant/api/resource
-- scope: *(String) tenant or api or resource
-- tenantId: *(String) tenant id
-- resource: (String) url-encoded resource path
-- apiId: (String) api id
-- }
function _M.deleteSubscription()
-- Validate body and create redisKey
local redisKey = validateSubscriptionBody()
-- Initialize and connect to redis
local red = redis.init(REDIS_HOST, REDIS_PORT, REDIS_PASS, 10000)
-- Return if subscription doesn't exist
redis.deleteSubscription(red, redisKey)
-- Add current redis connection in the ngx_lua cosocket connection pool
redis.close(red)
request.success(200, "Subscription deleted.")
end
--- Check the request JSON body for correct fields
-- @return redisKey subscription key for redis
function validateSubscriptionBody()
-- Read in the PUT JSON Body
ngx.req.read_body()
local args = ngx.req.get_body_data()
if not args then
request.err(400, "Missing request body.")
end
-- Convert json into Lua table
local decoded = cjson.decode(args)
-- Check required fields
local requiredFieldList = {"key", "scope", "tenantId"}
for i, field in ipairs(requiredFieldList) do
if not decoded[field] then
request.err(400, utils.concatStrings({"\"", field, "\" missing from request body."}))
end
end
-- Check if we're using tenant or resource or api
local resource = decoded.resource
local apiId = decoded.apiId
local redisKey
local prefix = utils.concatStrings({"subscriptions:tenant:", decoded.tenantId})
if decoded.scope == "tenant" then
redisKey = prefix
elseif decoded.scope == "resource" then
if resource ~= nil then
redisKey = utils.concatStrings({prefix, ":resource:", resource})
else
request.err(400, "\"resource\" missing from request body.")
end
elseif decoded.scope == "api" then
if apiId ~= nil then
redisKey = utils.concatStrings({prefix, ":api:", apiId})
else
request.err(400, "\"apiId\" missing from request body.")
end
else
request.err(400, "Invalid scope")
end
redisKey = utils.concatStrings({redisKey, ":key:", decoded.key})
return redisKey
end
--- Check for api id from uri and use existingAPI if it already exists in redis
-- @param red Redis client instance
-- @param uri Uri of request. Eg. /v1/apis/{id}
-- @param type type to look for in redis. "api" or "tenant"
function checkURIForExisting(red, uri, type)
local id, existing
local index = 1
-- Check if id is in the uri
for word in string.gmatch(uri, '([^/]+)') do
if index == 3 then
id = word
end
index = index + 1
end
-- Get object from redis
if id ~= nil then
if type == "api" then
existing = redis.getAPI(red, id)
if existing == nil then
request.err(404, utils.concatStrings({"Unknown API id ", id}))
end
elseif type == "tenant" then
existing = redis.getTenant(red, id)
if existing == nil then
request.err(404, utils.concatStrings({"Unknown Tenant id ", id}))
end
end
end
return existing
end
--- Parse the request uri to get the redisKey, tenant, and gatewayPath
-- @param requestURI String containing the uri in the form of "/resources/<tenant>/<path>"
-- @return list containing redisKey, tenant, gatewayPath
function parseRequestURI(requestURI)
local list = {}
for i in string.gmatch(requestURI, '([^/]+)') do
list[#list + 1] = i
end
if not list[1] or not list[2] then
request.err(400, "Request path should be \"/resources/<tenant>/<url-encoded-resource>\"")
end
return list --prefix, tenant, gatewayPath, apiKey
end
return _M