Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F115243
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
24 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/lib/open_api_spex/open_api/decode.ex b/lib/open_api_spex/open_api/decode.ex
new file mode 100644
index 0000000..3d944a5
--- /dev/null
+++ b/lib/open_api_spex/open_api/decode.ex
@@ -0,0 +1,402 @@
+defmodule OpenApiSpex.OpenApi.Decode do
+ # This module exposes functionality to convert an arbitrary map into a OpenApi struct.
+ alias OpenApiSpex.{
+ Components,
+ Contact,
+ Discriminator,
+ Encoding,
+ Example,
+ ExternalDocumentation,
+ Header,
+ Info,
+ License,
+ Link,
+ MediaType,
+ OAuthFlow,
+ OAuthFlows,
+ OpenApi,
+ Operation,
+ Parameter,
+ PathItem,
+ Reference,
+ RequestBody,
+ Response,
+ Schema,
+ SecurityScheme,
+ Server,
+ ServerVariable,
+ Tag,
+ Xml
+ }
+
+ def decode(%{"openapi" => _openapi, "info" => _info, "paths" => _paths} = map) do
+ map
+ |> to_struct(OpenApi)
+ |> prop_to_struct(:info, Info)
+ |> prop_to_struct(:paths, PathItems)
+ |> prop_to_struct(:servers, Servers)
+ |> prop_to_struct(:components, Components)
+ |> prop_to_struct(:tags, Tag)
+ |> prop_to_struct(:externalDocs, ExternalDocumentation)
+ end
+
+ defp struct_from_map(struct, map) when is_atom(struct) do
+ struct_from_map(struct.__struct__(), map)
+ end
+
+ defp struct_from_map(%_{} = struct, map) do
+ keys = struct |> Map.from_struct() |> Map.keys()
+
+ Enum.reduce(keys, struct, fn key, struct ->
+ case map_get(map, key) do
+ {_, value} -> Map.put(struct, key, value)
+ _ -> struct
+ end
+ end)
+ end
+
+ defp map_get(map, atom_key) when is_atom(atom_key) do
+ with %{^atom_key => value} <- map do
+ {atom_key, value}
+ else
+ _ -> map_get(map, to_string(atom_key))
+ end
+ end
+
+ defp map_get(map, string_key) when is_binary(string_key) do
+ case map do
+ %{^string_key => value} -> {string_key, value}
+ _ -> nil
+ end
+ end
+
+ defp embedded_ref_or_struct(list, mod) when is_list(list) do
+ list
+ |> Enum.map(fn
+ %{"$ref" => _} = v -> struct_from_map(Reference, v)
+ v -> to_struct(v, mod)
+ end)
+ end
+
+ defp embedded_ref_or_struct(map, mod) when is_map(map) do
+ map
+ |> Map.new(fn
+ {k, %{"$ref" => _} = v} ->
+ {k, struct_from_map(Reference, v)}
+
+ {k, v} ->
+ {k, to_struct(v, mod)}
+ end)
+ end
+
+ defp update_map_if_key_present(map, key, fun) do
+ case map do
+ %{^key => value} -> %{map | key => fun.(value)}
+ %{} -> map
+ end
+ end
+
+ # In some cases, e.g. Schema type we must convert values that are strings to atoms,
+ # for example Schema.type should be one of:
+ #
+ # :string | :number | :integer | :boolean | :array | :object
+ #
+ # This function, ensures that if the map has the key — it'll convert the corresponding value
+ # to an atom.
+ defp convert_value_to_atom_if_present(map, key),
+ do: update_map_if_key_present(map, key, &String.to_atom/1)
+
+ # In some cases, e.g. Schema type we must convert values that are list of strings to a list atoms,
+ #
+ # This function, ensures that if the map has the key — it'll convert the corresponding value
+ # to a list of atoms.
+ defp convert_value_to_list_of_atoms_if_present(map, key) do
+ map_fn = &String.to_atom/1
+ update_map_if_key_present(map, key, &Enum.map(&1, map_fn))
+ end
+
+ # The Schema.type and Schema.required keys require some special treatment since their
+ # values should be converted to atoms
+ defp prepare_schema(map) do
+ map
+ |> convert_value_to_atom_if_present("type")
+ |> convert_value_to_list_of_atoms_if_present("required")
+ end
+
+ defp to_struct(nil, _mod), do: nil
+
+ defp to_struct(tag, Tag) when is_binary(tag), do: tag
+
+ defp to_struct(map, Tag) when is_map(map) do
+ Tag
+ |> struct_from_map(map)
+ |> prop_to_struct(:externalDocs, ExternalDocumentation)
+ end
+
+ defp to_struct(list, Tag) when is_list(list) do
+ list
+ |> Enum.map(&to_struct(&1, Tag))
+ end
+
+ defp to_struct(map, Components) do
+ Components
+ |> struct_from_map(map)
+ |> prop_to_struct(:schemas, Schemas)
+ |> prop_to_struct(:responses, Responses)
+ |> prop_to_struct(:parameters, Parameters)
+ |> prop_to_struct(:examples, Examples)
+ |> prop_to_struct(:requestBodies, RequestBodies)
+ |> prop_to_struct(:headers, Headers)
+ |> prop_to_struct(:securitySchemes, SecuritySchemes)
+ |> prop_to_struct(:links, Links)
+ |> prop_to_struct(:callbacks, Callbacks)
+ end
+
+ defp to_struct(map, Link) do
+ Link
+ |> struct_from_map(map)
+ |> prop_to_struct(:server, Server)
+ |> prop_to_struct(:requestBody, RequestBody)
+ |> prop_to_struct(:parameters, Parameters)
+ end
+
+ defp to_struct(map, Links), do: embedded_ref_or_struct(map, Link)
+
+ defp to_struct(map, SecurityScheme) do
+ SecurityScheme
+ |> struct_from_map(map)
+ |> prop_to_struct(:flows, OAuthFlows)
+ end
+
+ defp to_struct(map, SecuritySchemes), do: embedded_ref_or_struct(map, SecurityScheme)
+
+ defp to_struct(map, OAuthFlow) do
+ struct_from_map(OAuthFlow, map)
+ end
+
+ defp to_struct(map, OAuthFlows) do
+ OAuthFlows
+ |> struct_from_map(map)
+ |> prop_to_struct(:implicit, OAuthFlow)
+ |> prop_to_struct(:password, OAuthFlow)
+ |> prop_to_struct(:clientCredentials, OAuthFlow)
+ |> prop_to_struct(:authorizationCode, OAuthFlow)
+ end
+
+ defp to_struct(%{"$ref" => _} = map, Schema), do: struct_from_map(Reference, map)
+
+ defp to_struct(%{"type" => type} = map, Schema)
+ when type in ~w(number integer boolean string) do
+ map
+ |> prepare_schema()
+ |> (&struct_from_map(Schema, &1)).()
+ |> prop_to_struct(:xml, Xml)
+ end
+
+ defp to_struct(%{"type" => "array"} = map, Schema) do
+ map
+ |> prepare_schema()
+ |> (&struct_from_map(Schema, &1)).()
+ |> prop_to_struct(:items, Schema)
+ |> prop_to_struct(:xml, Xml)
+ end
+
+ defp to_struct(%{"type" => "object"} = map, Schema) do
+ map
+ |> Map.update!("properties", fn v ->
+ v
+ |> Map.new(fn {k, v} ->
+ {String.to_atom(k), v}
+ end)
+ end)
+ |> prepare_schema()
+ |> (&struct_from_map(Schema, &1)).()
+ |> prop_to_struct(:properties, Schemas)
+ |> prop_to_struct(:externalDocs, ExternalDocumentation)
+ end
+
+ defp to_struct(%{"anyOf" => _valid_schemas} = map, Schema) do
+ Schema
+ |> struct_from_map(map)
+ |> prop_to_struct(:anyOf, Schemas)
+ |> prop_to_struct(:discriminator, Discriminator)
+ end
+
+ defp to_struct(%{"oneOf" => _valid_schemas} = map, Schema) do
+ Schema
+ |> struct_from_map(map)
+ |> prop_to_struct(:oneOf, Schemas)
+ |> prop_to_struct(:discriminator, Discriminator)
+ end
+
+ defp to_struct(%{"allOf" => _valid_schemas} = map, Schema) do
+ Schema
+ |> struct_from_map(map)
+ |> prop_to_struct(:allOf, Schemas)
+ |> prop_to_struct(:discriminator, Discriminator)
+ end
+
+ defp to_struct(%{"not" => _valid_schemas} = map, Schema) do
+ Schema
+ |> struct_from_map(map)
+ |> prop_to_struct(:not, Schemas)
+ end
+
+ defp to_struct(map, Schemas) when is_map(map), do: embedded_ref_or_struct(map, Schema)
+ defp to_struct(list, Schemas) when is_list(list), do: embedded_ref_or_struct(list, Schema)
+
+ defp to_struct(map, OAuthFlow) do
+ struct_from_map(OAuthFlow, map)
+ end
+
+ defp to_struct(map, Callback) do
+ map
+ |> Map.new(fn {k, v} ->
+ {k, to_struct(v, PathItem)}
+ end)
+ end
+
+ defp to_struct(map_or_list, Callbacks), do: embedded_ref_or_struct(map_or_list, Callback)
+
+ defp to_struct(map, Operation) do
+ Operation
+ |> struct_from_map(map)
+ |> prop_to_struct(:tags, Tag)
+ |> prop_to_struct(:externalDocs, ExternalDocumentation)
+ |> prop_to_struct(:responses, Responses)
+ |> prop_to_struct(:parameters, Parameters)
+ |> prop_to_struct(:requestBody, RequestBody)
+ |> prop_to_struct(:callbacks, Callbacks)
+ |> prop_to_struct(:servers, Server)
+ end
+
+ defp to_struct(map, RequestBody) do
+ RequestBody
+ |> struct_from_map(map)
+ |> prop_to_struct(:content, Content)
+ end
+
+ defp to_struct(map, RequestBodies), do: embedded_ref_or_struct(map, RequestBody)
+
+ defp to_struct(map, Parameter) do
+ map
+ |> convert_value_to_atom_if_present("name")
+ |> convert_value_to_atom_if_present("in")
+ |> convert_value_to_atom_if_present("style")
+ |> (&struct_from_map(Parameter, &1)).()
+ |> prop_to_struct(:examples, Examples)
+ |> prop_to_struct(:content, Content)
+ |> prop_to_struct(:schema, Schema)
+ end
+
+ defp to_struct(map_or_list, Parameters), do: embedded_ref_or_struct(map_or_list, Parameter)
+
+ defp to_struct(map, ServerVariable) do
+ struct_from_map(ServerVariable, map)
+ end
+
+ defp to_struct(map, ServerVariables) do
+ map
+ |> Map.new(fn {k, v} ->
+ {k, to_struct(v, ServerVariable)}
+ end)
+ end
+
+ defp to_struct(map, Server) do
+ Server
+ |> struct_from_map(map)
+ |> prop_to_struct(:variables, ServerVariables)
+ end
+
+ defp to_struct(list, Servers) when is_list(list) do
+ Enum.map(list, &to_struct(&1, Server))
+ end
+
+ defp to_struct(map, Response) do
+ Response
+ |> struct_from_map(map)
+ |> prop_to_struct(:headers, Headers)
+ |> prop_to_struct(:content, Content)
+ |> prop_to_struct(:links, Links)
+ end
+
+ defp to_struct(map, Responses), do: embedded_ref_or_struct(map, Response)
+
+ defp to_struct(map, MediaType) do
+ MediaType
+ |> struct_from_map(map)
+ |> prop_to_struct(:examples, Examples)
+ |> prop_to_struct(:encoding, Encoding)
+ |> prop_to_struct(:schema, Schema)
+ end
+
+ defp to_struct(map, Content) do
+ map
+ |> Map.new(fn {k, v} ->
+ {k, to_struct(v, MediaType)}
+ end)
+ end
+
+ defp to_struct(map, Encoding) do
+ map
+ |> Map.new(fn {k, v} ->
+ {k,
+ Encoding
+ |> struct_from_map(v)
+ |> convert_value_to_atom_if_present(:style)
+ |> prop_to_struct(:headers, Headers)}
+ end)
+ end
+
+ defp to_struct(map, Example), do: struct_from_map(Example, map)
+ defp to_struct(map_or_list, Examples), do: embedded_ref_or_struct(map_or_list, Example)
+
+ defp to_struct(map, Header) do
+ Header
+ |> struct_from_map(map)
+ |> prop_to_struct(:schema, Schema)
+ end
+
+ defp to_struct(map, Headers), do: embedded_ref_or_struct(map, Header)
+
+ defp to_struct(map, PathItem) do
+ PathItem
+ |> struct_from_map(map)
+ |> prop_to_struct(:delete, Operation)
+ |> prop_to_struct(:get, Operation)
+ |> prop_to_struct(:head, Operation)
+ |> prop_to_struct(:options, Operation)
+ |> prop_to_struct(:patch, Operation)
+ |> prop_to_struct(:post, Operation)
+ |> prop_to_struct(:put, Operation)
+ |> prop_to_struct(:trace, Operation)
+ |> prop_to_struct(:parameters, Parameters)
+ |> prop_to_struct(:servers, Servers)
+ end
+
+ defp to_struct(map, PathItems) do
+ map
+ |> Map.new(fn {k, v} ->
+ {k, to_struct(v, PathItem)}
+ end)
+ end
+
+ defp to_struct(map, Info) do
+ Info
+ |> struct_from_map(map)
+ |> prop_to_struct(:contact, Contact)
+ |> prop_to_struct(:license, License)
+ end
+
+ defp to_struct(list, mod) when is_list(list) and is_atom(mod),
+ do: Enum.map(list, &to_struct(&1, mod))
+
+ defp to_struct(map, module) when is_map(map) and is_atom(module),
+ do: struct_from_map(module, map)
+
+ defp prop_to_struct(map, key, mod) when is_map(map) and is_atom(key) and is_atom(mod) do
+ Map.update!(map, key, fn v ->
+ to_struct(v, mod)
+ end)
+ end
+end
diff --git a/test/open_api/decode_test.exs b/test/open_api/decode_test.exs
new file mode 100644
index 0000000..d2d1d0c
--- /dev/null
+++ b/test/open_api/decode_test.exs
@@ -0,0 +1,385 @@
+defmodule OpenApiSpex.OpenApi.DecodeTest do
+ use ExUnit.Case
+ use Plug.Test
+
+ alias OpenApiSpex.OpenApi
+
+ describe "OpenApiSpex.OpenApi.Decode.decode/1" do
+ test "OpenApi" do
+ # NOTE: This test could be split into many smaller tests, that is the goal!
+ # TODO: Move the spec to a setup below, where we could easily try a yaml version also
+ spec =
+ "./test/support/encoded_schema.json"
+ |> File.read!()
+ |> Jason.decode!()
+ |> OpenApiSpex.OpenApi.Decode.decode()
+
+ assert %OpenApi{
+ openapi: openapi,
+ info: info,
+ servers: servers,
+ paths: paths,
+ components: components,
+ security: security,
+ tags: tags,
+ externalDocs: externalDocs,
+ extensions: extensions
+ } = spec
+
+ assert "3.0.0" == openapi
+
+ assert %OpenApiSpex.Info{
+ title: _title,
+ version: _version,
+ description: _description,
+ contact: contact,
+ license: license
+ } = info
+
+ assert %OpenApiSpex.Contact{} = contact
+
+ assert %OpenApiSpex.License{} = license
+
+ assert nil == extensions
+
+ assert %OpenApiSpex.ExternalDocumentation{
+ description: _,
+ url: _
+ } = externalDocs
+
+ assert %OpenApiSpex.Components{
+ callbacks: callbacks,
+ schemas: schemas,
+ responses: responses,
+ examples: examples,
+ links: %{
+ "address" => link
+ },
+ requestBodies: requestBodies,
+ parameters: %{
+ "AcceptEncodingHeader" => components_parameters_parameter
+ },
+ securitySchemes: securitySchemes,
+ headers: %{
+ "api-version" => components_headers_header
+ }
+ } = components
+
+ assert %{
+ "test" => %OpenApiSpex.RequestBody{
+ description: "user to add to the system",
+ content: %{
+ "application/json" => media_type
+ },
+ required: false
+ }
+ } = requestBodies
+
+ assert %OpenApiSpex.MediaType{
+ schema: %OpenApiSpex.Reference{
+ "$ref": "#/components/schemas/User"
+ },
+ examples: %{
+ "user" => %OpenApiSpex.Example{
+ externalValue: "http://foo.bar/examples/user-example.json",
+ summary: "User Example",
+ value: nil,
+ description: nil
+ }
+ },
+ encoding: %{
+ "historyMetadata" => %OpenApiSpex.Encoding{
+ contentType: "application/xml; charset=utf-8",
+ style: :form,
+ explode: false,
+ allowReserved: false,
+ headers: %{
+ "X-Rate-Limit-Limit" => %OpenApiSpex.Header{
+ description: "The number of allowed requests in the current period",
+ schema: %OpenApiSpex.Schema{
+ type: :integer
+ }
+ }
+ }
+ }
+ },
+ example: nil
+ } = media_type
+
+ assert %{
+ "componentCallback" => componentCallback
+ } = callbacks
+
+ assert %{
+ "http://server-b.com?transactionId={$request.body#/id}" => %OpenApiSpex.PathItem{}
+ } = componentCallback
+
+ assert %{
+ "NotFound" => %OpenApiSpex.Response{
+ headers: %{
+ "X-Rate-Limit-Limit" => %OpenApiSpex.Header{
+ description: "The number of allowed requests in the current period",
+ schema: %OpenApiSpex.Schema{
+ type: :integer
+ }
+ }
+ },
+ content: %{
+ "application/json" => %OpenApiSpex.MediaType{
+ schema: %OpenApiSpex.Schema{
+ type: :string,
+ maxLength: 10
+ }
+ }
+ },
+ links: %{
+ "test" => %OpenApiSpex.Link{
+ operationId: "response-link-test"
+ }
+ },
+ description: "Entity not found."
+ }
+ } == responses
+
+ assert %{
+ "foo" => %OpenApiSpex.Example{}
+ } = examples
+
+ assert %OpenApiSpex.Parameter{
+ description: nil,
+ name: :"accept-encoding",
+ in: :header,
+ required: false,
+ allowEmptyValue: true,
+ schema: %OpenApiSpex.Schema{
+ example: "gzip",
+ type: :string
+ }
+ } == components_parameters_parameter
+
+ assert %{"User" => user_schema, "Admin" => admin_schema} = schemas
+
+ assert %OpenApiSpex.Schema{
+ allOf: [
+ %OpenApiSpex.Reference{
+ "$ref": "#/components/schemas/User"
+ },
+ %OpenApiSpex.Reference{
+ "$ref": "#/components/schemas/SpecialUser"
+ }
+ ],
+ discriminator: %OpenApiSpex.Discriminator{
+ propertyName: "userType"
+ }
+ } == admin_schema
+
+ assert %OpenApiSpex.Schema{
+ nullable: false,
+ readOnly: false,
+ writeOnly: false,
+ deprecated: false,
+ example: %{},
+ externalDocs: %OpenApiSpex.ExternalDocumentation{
+ description: "Find more info here",
+ url: "https://example.com"
+ },
+ properties: %{
+ first_name: %OpenApiSpex.Schema{
+ xml: %OpenApiSpex.Xml{
+ namespace: "http://example.com/schema/sample",
+ prefix: "sample"
+ }
+ }
+ }
+ } = user_schema
+
+ assert %OpenApiSpex.Link{
+ description: nil,
+ operationRef: nil,
+ operationId: "test",
+ requestBody: %OpenApiSpex.RequestBody{
+ description: "link payload",
+ content: %{
+ "application/json" => %OpenApiSpex.MediaType{
+ schema: %OpenApiSpex.Schema{}
+ }
+ }
+ },
+ parameters: %{
+ "ContentTypeHeader" => %OpenApiSpex.Reference{
+ "$ref": "#/components/parameters/ContentTypeHeader"
+ }
+ },
+ server: %OpenApiSpex.Server{
+ description: "Development server",
+ url: "https://development.gigantic-server.com/v1",
+ variables: %{}
+ }
+ } == link
+
+ assert %{
+ "api_key" => api_key_security_scheme,
+ "petstore_auth" => petstore_auth_security_scheme
+ } = securitySchemes
+
+ assert %OpenApiSpex.SecurityScheme{
+ flows: oauth_flows
+ } = petstore_auth_security_scheme
+
+ assert %OpenApiSpex.OAuthFlows{
+ implicit: oauth_flow
+ } = oauth_flows
+
+ assert %OpenApiSpex.OAuthFlow{
+ authorizationUrl: "http://example.org/api/oauth/dialog",
+ tokenUrl: nil,
+ refreshUrl: nil,
+ scopes: %{
+ "read:pets" => "read your pets",
+ "write:pets" => "modify pets in your account"
+ }
+ } = oauth_flow
+
+ assert %OpenApiSpex.Header{
+ description: "The version of the api to be used",
+ schema: %OpenApiSpex.Schema{
+ type: :string,
+ enum: ["beta"]
+ }
+ } == components_headers_header
+
+ assert [server] = servers
+
+ assert %OpenApiSpex.Server{
+ description: "Development server",
+ url: "https://development.gigantic-server.com/v1",
+ variables: serverVariables
+ } = server
+
+ assert %{
+ "username" => %OpenApiSpex.ServerVariable{
+ default: "demo",
+ description:
+ "this value is assigned by the service provider, in this example `gigantic-server.com`",
+ enum: nil
+ }
+ } = serverVariables
+
+ assert [tag] = tags
+
+ assert %OpenApiSpex.Tag{
+ description: "Pets operations",
+ externalDocs: %OpenApiSpex.ExternalDocumentation{
+ description: "Find more info here",
+ url: "https://example.com"
+ },
+ name: "pet"
+ } == tag
+
+ assert [
+ %{
+ "petstore_auth" => ["write:pets", "read:pets"]
+ }
+ ] == security
+
+ assert %{
+ "/example" => %OpenApiSpex.PathItem{
+ summary: "/example summary",
+ description: "/example description",
+ servers: [%OpenApiSpex.Server{}],
+ parameters: [
+ %OpenApiSpex.Reference{
+ "$ref": "#/components/parameters/ContentTypeHeader"
+ }
+ ],
+ post: %OpenApiSpex.Operation{
+ parameters: [
+ %OpenApiSpex.Reference{},
+ %OpenApiSpex.Reference{},
+ %OpenApiSpex.Parameter{}
+ ],
+ deprecated: false,
+ operationId: "example-post-test",
+ requestBody: requestBody,
+ callbacks: operationCallbacks,
+ responses: operationResponses,
+ security: operationSecurity,
+ tags: ["test"],
+ summary: "/example post summary",
+ description: "/example post description",
+ externalDocs: %OpenApiSpex.ExternalDocumentation{
+ description: "Find more info here",
+ url: "https://example.com"
+ }
+ }
+ }
+ } = paths
+
+ assert [
+ %{
+ "petstore_auth" => ["write:pets"]
+ }
+ ] == operationSecurity
+
+ assert %{
+ "200" => %OpenApiSpex.Response{}
+ } = operationResponses
+
+ assert %{
+ "operationCallback" => %{
+ "http://server-a.com?transactionId={$request.body#/id}" =>
+ %OpenApiSpex.PathItem{}
+ }
+ } = operationCallbacks
+
+ assert %OpenApiSpex.Schema{
+ properties: properties
+ } =
+ get_in(
+ requestBody,
+ [:content, "application/json", :schema]
+ |> Enum.map(&Access.key/1)
+ )
+
+ passengers =
+ get_in(
+ properties,
+ [:data, :properties, :passengers]
+ |> Enum.map(&Access.key/1)
+ )
+
+ assert %OpenApiSpex.Schema{} = passengers
+
+ assert %OpenApiSpex.Schema{
+ type: :string,
+ enum: ["adult", "child"]
+ } =
+ get_in(
+ passengers,
+ [:items, :properties, :type]
+ |> Enum.map(&Access.key/1)
+ )
+
+ test_conn =
+ conn(:post, "/example?myParam=1", %{
+ "data" => %{
+ "first_name" => "Bob",
+ "given_name" => "Builder",
+ "phone_number" => "+441111111111"
+ }
+ })
+ |> put_req_header("content-type", "application/json")
+ |> put_req_header("accept-encoding", "gzip")
+
+ test_conn = fetch_query_params(test_conn)
+
+ assert {:ok, validation_result} =
+ OpenApiSpex.cast_and_validate(
+ spec,
+ spec.paths["/example"].post,
+ test_conn,
+ "application/json"
+ )
+ end
+ end
+end
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Wed, Nov 27, 3:52 PM (1 d, 17 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
40686
Default Alt Text
(24 KB)
Attached To
Mode
R22 open_api_spex
Attached
Detach File
Event Timeline
Log In to Comment