diff --git a/src/normalize.jl b/src/normalize.jl index 7770ef8..b98cab0 100644 --- a/src/normalize.jl +++ b/src/normalize.jl @@ -587,9 +587,11 @@ function _portable_schema_ids(context::NormalizationContext, schemas) registry, context.resolver.root.resource.retrieval, ) - labels = Dict{Resources.ResourceId,String}( - primary.id => "root-" * first(_content_digest(primary.contents), 20), - ) + # The primary label is constant. Labels of referenced resources derive from + # their parent label and reference location, so an unrelated root edit must + # not rotate every generated schema ID. IDs are scoped to one generated + # module, and the "external-" and "resource-" prefixes cannot collide with it. + labels = Dict{Resources.ResourceId,String}(primary.id => "root") references = collect(getfield(template, :references)) while true diff --git a/test/references.jl b/test/references.jl index c429c0e..b602fc3 100644 --- a/test/references.jl +++ b/test/references.jl @@ -315,6 +315,104 @@ @test !occursin("token=secret", credentialed) end + @testset "generated schema IDs survive unrelated root edits" begin + schema_ids(source) = [ + match.captures[1] for match in eachmatch( + r"^ \(id = \"([^\"]+)\", retrieval = "m, + source, + ) + ] + response(schema) = OpenAPI.obj( + "200" => OpenAPI.obj( + "description" => "value", + "content" => OpenAPI.obj( + "application/json" => OpenAPI.obj("schema" => schema), + ), + ), + ) + document = minimal_openapi( + "3.1.1", + OpenAPI.obj( + "/local" => OpenAPI.obj( + "get" => OpenAPI.obj( + "operationId" => "getLocal", + "responses" => response( + OpenAPI.obj("\$ref" => "#/components/schemas/Local"), + ), + ), + ), + "/value" => OpenAPI.obj( + "get" => OpenAPI.obj( + "operationId" => "getValue", + "responses" => response( + OpenAPI.obj("\$ref" => "./common.json#/\$defs/Value"), + ), + ), + ), + ), + ) + document["components"] = OpenAPI.obj( + "schemas" => OpenAPI.obj( + "Local" => OpenAPI.obj( + "type" => "object", + "required" => ["name"], + "properties" => OpenAPI.obj( + "name" => OpenAPI.obj("type" => "string"), + ), + ), + ), + ) + common = OpenAPI.obj( + "\$defs" => OpenAPI.obj( + "Value" => OpenAPI.obj( + "type" => "object", + "required" => ["id", "nested"], + "properties" => OpenAPI.obj( + "id" => OpenAPI.obj("type" => "integer"), + "nested" => OpenAPI.obj("\$ref" => "./nested.json"), + ), + ), + ), + ) + nested = OpenAPI.obj( + "type" => "object", + "properties" => OpenAPI.obj("flag" => OpenAPI.obj("type" => "boolean")), + ) + directory = mktempdir() + write(joinpath(directory, "common.json"), JSON.json(common)) + write(joinpath(directory, "nested.json"), JSON.json(nested)) + path = joinpath(directory, "openapi.json") + write(path, JSON.json(document)) + before = OpenAPI.client(path; name = "StableIdClient") + + edited = deepcopy(document) + edited["info"]["version"] = "2.0.0" + edited["info"]["description"] = "An unrelated root edit." + write(path, JSON.json(edited)) + after = OpenAPI.client(path; name = "StableIdClient") + + ids = schema_ids(before) + @test length(ids) == 3 + @test "https://openapi.invalid/schema/root.json" in ids + @test count(id -> occursin("/schema/external-", id), ids) == 2 + @test schema_ids(after) == ids + + # Only the header and the embedded root document change. + before_lines = split(before, '\n') + after_lines = split(after, '\n') + @test length(before_lines) == length(after_lines) + changed = [ + index for index in eachindex(before_lines) + if before_lines[index] != after_lines[index] + ] + @test length(changed) == 2 + @test startswith(after_lines[changed[1]], "# Generated by OpenAPI.jl") + @test startswith( + after_lines[changed[2]], + " (id = \"https://openapi.invalid/schema/root.json\"", + ) + end + @testset "HTTP origin policy and redirects" begin external_schema = JSON.json( OpenAPI.obj(