Files
2026-07-13 12:32:21 +08:00

184 lines
4.4 KiB
Lua

---
-- JSON schema validation.
--
--
local _M = {}
local lrucache = require "resty.lrucache"
local jsonschema = require "resty.ljsonschema"
local metaschema = require "resty.ljsonschema.metaschema"
local cjson = require "cjson"
local sha256_hex = require("kong.tools.sha256").sha256_hex
local cycle_aware_deep_copy = require("kong.tools.table").cycle_aware_deep_copy
local type = type
local cjson_encode = cjson.encode
---@class kong.db.schema.json.schema_doc : table
---
---@field id string|nil
---@field ["$id"] string|nil
---@field ["$schema"] string|nil
---@field type string
-- The correct identifier for draft-4 is 'http://json-schema.org/draft-04/schema#'
-- with the fragment (#) intact. Newer editions use an identifier _without_
-- the fragment (e.g. 'https://json-schema.org/draft/2020-12/schema'), so we
-- will be lenient when comparing these strings.
assert(type(metaschema.id) == "string",
"JSON metaschema .id not defined or not a string")
local DRAFT_4_NO_FRAGMENT = metaschema.id:gsub("#$", "")
local DRAFT_4 = DRAFT_4_NO_FRAGMENT .. "#"
_M.DRAFT_4 = DRAFT_4
---@type table<string, kong.db.schema.json.schema_doc>
local schemas = {}
-- Creating a json schema validator is somewhat expensive as it requires
-- generating and evaluating some Lua code, so we memoize this step with
-- a local LRU cache.
local cache = lrucache.new(1000)
local schema_cache_key
do
local cache_keys = setmetatable({}, { __mode = "k" })
---
-- Generate a unique cache key for a schema document.
--
---@param schema kong.db.schema.json.schema_doc
---@return string
function schema_cache_key(schema)
local cache_key = cache_keys[schema]
if not cache_key then
cache_key = "hash://" .. sha256_hex(cjson_encode(schema))
cache_keys[schema] = cache_key
end
return cache_key
end
end
---@param id any
---@return boolean
local function is_draft_4(id)
return id
and type(id) == "string"
and (id == DRAFT_4 or id == DRAFT_4_NO_FRAGMENT)
end
---@param id any
---@return boolean
local function is_non_draft_4(id)
return id
and type(id) == "string"
and (id ~= DRAFT_4 and id ~= DRAFT_4_NO_FRAGMENT)
end
---
-- Validate input according to a JSON schema document.
--
---@param input any
---@param schema kong.db.schema.json.schema_doc
---@return boolean? ok
---@return string? error
local function validate(input, schema)
assert(type(schema) == "table")
-- we are validating a JSON schema document and need to ensure that it is
-- not using supported JSON schema draft/version
if is_draft_4(schema.id or schema["$id"])
and is_non_draft_4(input["$schema"])
then
return nil, "unsupported document $schema: '" .. input["$schema"] ..
"', expected: " .. DRAFT_4
end
local cache_key = schema_cache_key(schema)
local validator = cache:get(cache_key)
if not validator then
validator = assert(jsonschema.generate_validator(schema, {
name = cache_key,
-- lua-resty-ljsonschema's default behavior for detecting an array type
-- is to compare its metatable against `cjson.array_mt`. This is
-- efficient, but we can't assume that all inputs will necessarily
-- conform to this, so we opt to use the heuristic approach instead
-- (determining object/array type based on the table contents).
array_mt = false,
}))
cache:set(cache_key, validator)
end
return validator(input)
end
---@type table
_M.metaschema = metaschema
_M.validate = validate
---
-- Validate a JSON schema document.
--
-- This is primarily for use in `kong.db.schema.metaschema`
--
---@param input kong.db.schema.json.schema_doc
---@return boolean? ok
---@return string? error
function _M.validate_schema(input)
local typ = type(input)
if typ ~= "table" then
return nil, "schema must be a table"
end
return validate(input, _M.metaschema)
end
---
-- Add a JSON schema document to the local registry.
--
---@param name string
---@param schema kong.db.schema.json.schema_doc
function _M.add_schema(name, schema)
schemas[name] = cycle_aware_deep_copy(schema, true)
end
---
-- Retrieve a schema from local storage by name.
--
---@param name string
---@return kong.db.schema.json.schema_doc? schema
function _M.get_schema(name)
return schemas[name]
end
---
-- Remove a schema from local storage by name (if it exists).
--
---@param name string
function _M.remove_schema(name)
schemas[name] = nil
end
return _M