blob: de1273469bd5d58daea583049446d25898d5bf81 [file]
--
-- Licensed to the Apache Software Foundation (ASF) under one or more
-- contributor license agreements. See the NOTICE file distributed with
-- this work for additional information regarding copyright ownership.
-- The ASF licenses this file to You under the Apache License, Version 2.0
-- (the "License"); you may not use this file except in compliance with
-- the License. You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
--- Test fixture loader for AI proxy mock responses.
-- Loads fixture files from t/fixtures/ and serves them based on the
-- X-AI-Fixture request header.
--
-- Supported headers:
-- X-AI-Fixture: <path> -- fixture file path relative to t/fixtures/
-- X-AI-Fixture-Status: <code> -- optional HTTP status code (default 200)
local _M = {}
local io = io
local ngx = ngx
local fixture_base_dir
local function get_fixture_dir()
if fixture_base_dir then
return fixture_base_dir
end
local constants = require("apisix.constants")
local lua_home = constants and constants.apisix_lua_home
if type(lua_home) ~= "string" or lua_home == "" then
return nil, "apisix_lua_home is not initialized"
end
fixture_base_dir = lua_home .. "/t/fixtures/"
return fixture_base_dir
end
function _M.load(name)
if type(name) ~= "string" or #name == 0 or #name > 256 then
return nil, "invalid fixture name"
end
if name:sub(1, 1) == "/" or name:find("..", 1, true) then
return nil, "invalid fixture name"
end
if not name:match("^[%w%._/-]+$") then
return nil, "invalid fixture name"
end
local dir, dir_err = get_fixture_dir()
if not dir then
return nil, dir_err
end
local path = dir .. name
local f = io.open(path, "r")
if not f then
return nil, "fixture not found"
end
local content, read_err = f:read("*a")
local ok, close_err = f:close()
if not content then
return nil, "failed to read fixture: " .. (read_err or "unknown error")
end
if not ok then
return nil, "failed to close fixture: " .. (close_err or "unknown error")
end
return content
end
-- Replace {{model}} placeholder with the model from request body.
local function apply_template(content)
if not content:find("{{model}}", 1, true) then
return content
end
ngx.req.read_body()
local body_data = ngx.req.get_body_data()
if not body_data then
return content:gsub("{{model}}", "unknown")
end
local json = require("cjson.safe")
local body = json.decode(body_data)
if not body or not body.model then
return content:gsub("{{model}}", "unknown")
end
return content:gsub("{{model}}", function() return body.model end)
end
-- Serve a fixture based on the X-AI-Fixture request header.
-- For .sse files: sets Content-Type to text/event-stream and sends content as-is.
-- For other files: sets Content-Type to application/json.
-- Optional X-AI-Fixture-Status header overrides the HTTP status code.
function _M.dispatch()
local headers = ngx.req.get_headers()
local fixture_name = headers["x-ai-fixture"]
if not fixture_name then
ngx.status = 400
ngx.say("missing X-AI-Fixture header")
return
end
local content, err = _M.load(fixture_name)
if not content then
ngx.status = 500
ngx.say(err)
return
end
content = apply_template(content)
local status = tonumber(headers["x-ai-fixture-status"])
if status then
ngx.status = status
end
if fixture_name:match("%.sse$") then
ngx.header["Content-Type"] = "text/event-stream"
ngx.header["Cache-Control"] = "no-cache"
ngx.header["Transfer-Encoding"] = "chunked"
ngx.print(content)
ngx.flush(true)
else
ngx.header["Content-Type"] = "application/json"
ngx.print(content)
end
end
return _M